Основы веб-разработки

Настройка веб-слоя

Веб-слой — это часть приложения, которая принимает HTTP-запросы и отдаёт ответы: сервер, маршрутизация, контроллеры. У него есть решения, которые не принадлежат ни одному контроллеру и ни одному маршруту, потому что касаются всего приложения сразу.

Класс WebConfigurerAdapterСервер configureServer()CORS configureCors()

Таких решений два вида:

  • Как устроен сам сервер — на каком адресе он слушает, сколько процессов держит, сколько памяти отводит на запрос, как долго ждёт медленный обработчик.
  • Кому браузер разрешит обращаться — политика кросс-доменных запросов, CORS.

Первое решается один раз при запуске, второе — на каждом запросе.

Проблема. Такие настройки легко расползаются по проекту: порт указан при запуске, число воркеров — в systemd-юните, ограничение памяти — в переменной окружения, CORS — где-то в коде инициализации. Через полгода на вопрос «откуда взялось это значение» уходит больше времени, чем на саму правку. Вдобавок ничто из этого не проверяется: опечатка в имени параметра остаётся опечаткой до самого запуска.

Решение. Winter собирает эти решения в один класс — конфигуратор веб-слоя. Он написан обычным кодом, поэтому его проверяют и типы, и подсказки редактора; лежит в проекте рядом с остальным кодом; и подключается сам — искать место для регистрации не нужно.

Класс-конфигуратор

Достаточно унаследовать WebConfigurerAdapter и переопределить нужные методы:

main/WebConfig.php
<?php

namespace Main;

use Flytachi\Winter\Kernel\App\ApplicationArguments;
use Flytachi\Winter\Kernel\App\Config\CorsRegistry;
use Flytachi\Winter\Kernel\App\Config\ServerSettings;
use Flytachi\Winter\Kernel\App\Config\WebConfigurerAdapter;

final class WebConfig extends WebConfigurerAdapter
{
  public function configureServer(ServerSettings $server, ApplicationArguments $args): void
  {
      $server->port($args->int('port', 8000))
             ->workers(4)
             ->requestTimeout(30);
  }

  public function configureCors(CorsRegistry $cors): void
  {
      $cors->allowedOrigins('https://app.example.com')
           ->allowedHeaders('Content-Type', 'Authorization')
           ->allowCredentials();
  }
}

Регистрировать класс нигде не нужно. Его находит тот же скан, что находит контроллеры, — значит, имя класса и его расположение произвольны, лишь бы файл лежал в сканируемом каталоге (не в resources/, storage/ или vendor/).

Что наследовать

Есть два варианта, и разница между ними — в том, заставит ли вас язык реализовать оба метода:

Наследуете Что получаете Когда выбирать
WebConfigurerAdapter Оба метода уже реализованы пустыми — переопределяете только нужный Обычный случай
WebConfigurer (интерфейс) Ничего не реализовано — PHP потребует оба метода Когда хотите, чтобы про второй нельзя было забыть

WebConfigurerAdapter — это тот же интерфейс с пустыми заглушками, так что выбор между ними ни на что не влияет, кроме удобства.

Классы, с которыми вы работаете

В сигнатурах обоих методов встречаются три класса. Знать их устройство необязательно — каждый решает ровно одну задачу:

Класс Откуда берётся Что это
ServerSettings Первый аргумент configureServer() Настройки сервера
ApplicationArguments Второй аргумент configureServer() Разобранная командная строка
CorsRegistry Аргумент configureCors() Политика CORS

ServerSettings — набор параметров запускаемого сервера: адрес, число процессов, лимиты памяти и времени. Устроен как текучий билдер: каждый метод возвращает сам объект, поэтому вызовы цепляются в одну строку. Объект приходит уже заполненным — значениями по умолчанию и тем, что нашлось в переменных окружения, — так что достаточно поправить нужное, а не описывать всё заново.

ApplicationArguments — то, с чем приложение запустили: php call run --port=9000 превращается в объект, у которого можно спросить значение по имени. Нужен, чтобы источник настройки выбирали вы: взять порт из флага, из .env или написать числом в коде — решение остаётся за вами, а не за фреймворком.

CorsRegistry — накопитель политики кросс-доменных запросов. Тоже текучий билдер, но, в отличие от ServerSettings, приходит пустым: пока не вызван ни один его метод, CORS-заголовки не отправляются вовсе.

Два метода

Методы отвечают за разные вещи и вызываются в разные моменты:

configureServer() configureCors()
Что настраивает Сам сервер: адрес, процессы, память, пределы Политику доступа для браузеров
Когда вызывается Один раз при запуске Один раз при запуске
Когда действует На всё время работы приложения На каждом запросе
Что принимает ServerSettings и ApplicationArguments CorsRegistry
Где описан Настройка сервера — ниже CORS — ниже

configureServer(ServerSettings $server, ApplicationArguments $args) — настраивает процесс, в котором приложение живёт. Выполняется в мастер-процессе, до того как появятся воркеры, поэтому здесь задаётся то, что потом не изменить: порт, число процессов, лимит памяти на воркер, максимальный размер запроса, дедлайн обработки. Второй аргумент даёт доступ к разобранной командной строке php call run, так что источник значения выбираете вы: флаг, .env или константа в коде.

configureCors(CorsRegistry $cors) — описывает, каким сторонним источникам браузер разрешит читать ответы приложения. Сама политика собирается при запуске, но применяется на каждом ответе, включая 404 и ошибки.

Не переопределили метод — ничего не произойдёт: сервер поднимется с настройками по умолчанию, а CORS-заголовки просто не будут отправляться.

Несколько конфигураторов

Два контракта ведут себя по-разному, и различие — не в теме, а в арности.

WebConfigurer — сколько угодно классов, где угодно, включая пакеты. Все получают один и тот же CorsRegistry, вклады складываются: два класса, каждый со своим allowedOrigins(), дадут общий список источников. CORS — набор правил, и объединение двух наборов осмысленно.

ServerConfigurerровно один, и только в приложении. ServerSettings это один объект, изменяемый на месте: два вклада не складываются, второй затирает первый.

Контракт Кто вправе Сколько
WebConfigurerconfigureCors() приложение и пакеты сколько угодно, складываются
ServerConfigurerconfigureServer() только приложение один

Сервер настраивает только приложение

ServerConfigurer, найденный внутри импортированного пакета, — ошибка загрузки с именем класса и пакета:

text
Only the application may configure the server; found AcmeBillingTuning in acme/billing.

Иначе пакет, тюнящий worker_num, тихо переопределял бы выбор приложения — а кто победит, решал бы порядок обхода файловой системы. Пакету остаётся WebConfigurer: он может дополнить политику CORS, но не двигать адрес сервера.

Больше одного ServerConfigurer в самом приложении — тоже ошибка: у сервера один хозяин.

Порядок: приложение последнее

Конфигураторы применяются в определённом порядке — сначала пакеты, в порядке объявления #[Import], затем приложение. Для складывающихся реестров это влияет только на порядок записей; там, где вклады перекрывают друг друга, приложение выигрывает всегда.

Настройка сервера

configureServer() вызывается один раз при запуске, в мастер-процессе, до того как появятся воркеры. Всё, что здесь задано, действует до остановки приложения: поменять адрес или лимит памяти на лету нельзя.

main/WebConfig.php
public function configureServer(ServerSettings $server, ApplicationArguments $args): void
{
  $server->port($args->int('port', 8000))
         ->workers(4)
         ->profile(Profile::Balance)
         ->requestTimeout(30)
         ->memoryLimit('256M');
}

Вызовы цепляются — каждый метод возвращает сам объект настроек.

Что здесь вообще настраивается

Winter работает на Swoole, и это меняет смысл почти всех привычных цифр. Стоит разобрать модель, иначе непонятно, зачем нужен каждый параметр.

Приложение живёт в нескольких воркерах — отдельных процессах. Каждый воркер поднимает PHP один раз и дальше обрабатывает запросы, не умирая между ними. При этом внутри одного воркера запросы идут не по очереди: каждый выполняется в своей корутине, и пока один ждёт ответа базы, воркер обслуживает остальные.

Отсюда два следствия, определяющих все настройки на этой странице:

  • Память общая. Все одновременные запросы воркера живут в одной куче. Запрос, который съест лишнее, убивает не себя, а весь процесс — вместе со всеми запросами, которые тот в этот момент держал.
  • Память не освобождается сама. Процесс не завершается после ответа, поэтому всё, что накопилось, остаётся до следующего запроса.

Поэтому настройки сервера — это по сути ответ на один вопрос: сколько запросов воркер может держать одновременно, не рискуя упасть. Дальше — по группам.

Адрес

Метод По умолчанию Назначение
host(string) 0.0.0.0 Сетевой интерфейс, который слушает сервер
port(int) 8000 Порт

0.0.0.0 означает «все интерфейсы» — сервер виден и снаружи. Если приложение стоит за nginx на той же машине, разумно сузить до 127.0.0.1, чтобы порт не торчал в сеть.

Значения уже заполнены из флагов --host и --port, так что трогать эти методы нужно, только если вы хотите брать адрес откуда-то ещё.

Процессы

Метод По умолчанию Назначение
workers(int) по числу ядер Сколько процессов обрабатывают запросы
taskWorkers(int) нет Отдельные процессы под фоновые задачи Swoole

workers() — это про использование процессорных ядер, а не про параллелизм запросов. Параллелизм внутри воркера обеспечивают корутины, и один процесс уже держит сотни запросов сразу. Воркеры нужны, чтобы задействовать все ядра: пока один процесс считает, остальные работают.

Помните, что лимит памяти умножается на их число — четыре воркера по 256M требуют гигабайт.

Замена воркеров

Метод По умолчанию Назначение
maxRequest(int) из профиля Сколько запросов отработает воркер, прежде чем его заменят свежим. 0 — никогда
maxRequestGrace(int) десятая часть maxRequest Разброс, чтобы воркеры не перезапускались разом

Долгоживущий процесс накапливает мусор, и от этого есть лекарство: периодически заменять воркер на новый. Но лекарство не бесплатное — замена убивает запросы, которые воркер в этот момент обслуживал, и клиент видит оборванное соединение, хотя в логах не появляется ничего.

Поэтому Winter меряет, а не перестраховывается. Течёт не всякий запрос: замеры на живом сервере дали ноль прироста кучи на 4,6 млн обычных запросов. Утечка в 180 байт возникает только там, где запрос взводит таймерCoroutine::sleep() и всё, что на нём построено. Отсюда порог: воркер заменяется, когда накопленная утечка могла бы дойти до 20 % его лимита памяти.

maxRequestGrace решает отдельную беду: при ровном трафике воркеры досчитывают до одного и того же числа почти одновременно и перезапускаются вместе — а вместе с ними разом холодеют все пулы соединений. Swoole прибавляет к лимиту случайную величину до grace, и перезапуски расходятся во времени.

Если ваши обработчики никогда не спят, замену можно отключить (maxRequest(0)) — платить оборванными запросами будет не за что.

Одновременная нагрузка

Метод По умолчанию Назначение
maxConcurrency(int) из профиля Сколько запросов воркер обрабатывает одновременно
maxConnections(int) из профиля Потолок одновременных TCP-соединений
idleConnectionTimeout(int) выключено Через сколько секунд молчания закрыть соединение

maxConcurrency() — главный предохранитель. Каждый запрос в работе занимает память: около 78 КБ уходит на сам каркас — корутину, объекты запроса и ответа, — и сверх того столько, сколько выделит ваш код. Когда одновременных запросов слишком много, воркер упирается в лимит и умирает вместе со всеми ними. Ограничение превращает эту катастрофу в очередь: лишние запросы просто ждут.

Ожидание в очереди засчитывается в дедлайн запроса, так что подвисший клиент не получит свежие тридцать секунд сверх тех, что уже прождал.

maxConnections() — потолок сокетов. Здесь работает второе ограничение, никак не связанное с памятью: сокет — это файловый дескриптор, а их число задаёт ulimit -n. Выведенное из профиля значение Winter подрезает под этот предел, чтобы объявленное число совпадало с фактическим. Заданное вручную — оставляет как есть: предупреждение Swoole о превышении дойдёт до того, кто его запросил, и подскажет поднять ulimit.

idleConnectionTimeout() — про keep-alive. Открытое соединение занимает около 68 КБ, даже когда клиент ничего не просит, и эта память считается в лимит PHP. Воркер со стандартными 128 МБ умирает примерно на 1900 простаивающих соединениях. Если клиентов много и они держат соединения подолгу, поставьте таймаут молчания.

Пределы одного запроса

Метод По умолчанию Назначение
maxRequestSize(int $bytes) 8 МБ Наибольший запрос вместе с заголовками; сверх — 413
requestTimeout(float $sec) 30 Сколько запрос может идти, прежде чем сервер перестанет ждать. 0 — без предела

maxRequestSize(). Тело запроса лежит в куче воркера всё время обработки, а куча общая. Поэтому предел здесь не про диск и не про сеть, а про то, сколько одновременных загрузок переживёт процесс: 64 МБ при сотне параллельных загрузок — это 6,4 ГБ, и воркер умрёт раньше, чем закончится закачка. Значение по умолчанию совпадает с post_max_size в PHP, чтобы не удивлять привычных цифр.

requestTimeout(). Своего таймаута запроса у Swoole нет — дедлайн держит сторож, который отменяет корутину зависшего запроса. Отмена корректная: finally и defer отрабатывают, транзакции закрываются, соединения возвращаются в пул, клиент получает 504.

Важно понимать границу: прерывается запрос, который чего-то ждёт — базу, внешний сервис, файл. Запрос, который жжёт процессор в цикле без ввода-вывода, не прервётся, потому что пока он не отпустит поток, в воркере не выполняется ничего, включая сам сторож.

Отдельным маршрутам дедлайн переопределяется атрибутом #[Timeout] — см. Маршрутизацию.

Память

Метод По умолчанию Назначение
memoryLimit(string) ini PHP Потолок памяти каждого воркера
memoryTrimThreshold(string) из профиля Сколько простаивающей памяти воркер держит, прежде чем вернуть её системе. 0 — не возвращать

memoryLimit() — это memory_limit PHP, применяемый при старте каждого воркера. От него считается почти всё остальное: и допустимый параллелизм, и порог замены воркера, и порог возврата памяти.

Лимит памяти — на воркер, а не на приложение

memoryLimit('512M') при восьми воркерах означает 4 ГБ в худшем случае. Winter считает это произведение при старте и предупреждает, если машина столько не держит, но запуску не мешает: перезаклад бывает осознанным решением.

memoryTrimThreshold() — про возврат памяти операционной системе. После всплеска нагрузки воркер продолжает удерживать выделенное, хотя оно уже не нужно. Проверка после каждого ответа возвращает излишек, но операция не бесплатна: когда памяти много, она занимает около 80 мс и видна в хвосте задержек. Поэтому порог масштабируется от лимита, а не задан фиксированным числом.

Статика

php
$server->staticPath('resources/static');   // resources/static/app.css → /app.css

Отдача статики выключена по умолчанию — пока каталог не указан, ни один файл наружу не отдаётся.

Статика идёт мимо PHP

Такие файлы отдаёт сам Swoole, до того как управление попадёт в PHP. Значит, к ним не применяются ни middleware, ни CORS, ни логирование запросов — маршрутизация о них попросту не знает.

Всё остальное

Любой параметр Swoole, для которого нет отдельного метода, задаётся напрямую:

php
$server->set('open_http2_protocol', true);

Профили

Задавать пределы поштучно приходится редко: почти все они выводятся из профиля.

Профиль — это способ не считать вручную взаимосвязанные лимиты. Он отвечает на один-единственный вопрос: сколько памяти расходует один ваш запрос. Из ответа арифметически следует всё остальное — сколько запросов воркер может держать одновременно, сколько соединений открыть, когда возвращать память системе.

php
$server->profile(Profile::Balance);

Все три рабочих профиля одинаково надёжны. Они не торгуют устойчивостью ради скорости — наоборот, каждый существует ровно затем, чтобы воркер не перенапрягал память и не падал, унося с собой обслуживаемые запросы. Различаются они только предположением о размере запроса:

Профиль Бюджет запроса Для каких приложений
Stable 512 КБ Тяжёлые запросы: отчёты, выгрузки, широкие выборки
Balance 128 КБ Обычный CRUD — значение по умолчанию
Performance 64 КБ Лёгкие запросы: тонкий API, прокси, интеграционный шлюз

Бюджет — это память на собственную работу запроса: загруженные сущности, собранная строка ответа. Каркас (около 78 КБ на запрос и 68 КБ на соединение) добавляется поверх и от профиля не зависит.

Меньший бюджет означает больший параллелизм: при одной и той же памяти Performance пропустит одновременно втрое больше запросов, чем Stable. Поэтому выбор профиля — не «насколько я осторожен», а «насколько крупные у меня запросы». Причём это измеряется, а не угадывается:

php
$before = memory_get_usage();
// ... обработчик ...
$after = memory_get_usage();   // меньше 64 КБ → Performance; больше 200 КБ → Stable

Ошибка в выборе не делает приложение безопаснее: если запросы тяжелее заявленного, Performance не спасёт — он лишь позволит набрать больше одновременных запросов, чем воркер выдержит.

`Stress` — только для замеров

Четвёртый профиль, Profile::Stress, единственный снимает ограничители: без предела параллелизма, без замены воркеров, без возврата памяти. Он существует не для того, чтобы «выжать ещё» — пропускная способность упирается в потолок задолго до памяти. Он убирает периодические помехи, искажающие замер: возврат памяти даёт паузы, видимые в p99, замена воркера опустошает пул соединений на середине прогона, сторож запросов крутит собственный таймер.

Под этим профилем воркер может исчерпать память, а на долгом прогоне утечка накапливается без замены. Для ограниченного по времени бенчмарка это приемлемо, для продакшена — нет.

Всё, что решает профиль, — это значения по умолчанию. Явный вызов maxConcurrency(), maxRequest() или переменная окружения перекрывают его вне зависимости от порядка вызовов.

Настройка через .env

Те же параметры задаются переменными окружения — удобно, когда значения разные на стенде и в проде:

Переменная Соответствует
SERVER_PROFILE profile()
SERVER_WORKERS workers()
SERVER_TASKS taskWorkers()
SERVER_MAX_REQUEST maxRequest()
SERVER_MAX_REQUEST_GRACE maxRequestGrace()
SERVER_MAX_REQUEST_SIZE maxRequestSize()
SERVER_MAX_CONNECTIONS maxConnections()
SERVER_MAX_CONCURRENCY maxConcurrency()
SERVER_IDLE_TIMEOUT idleConnectionTimeout()
SERVER_MEMORY_LIMIT memoryLimit()
SERVER_MEMORY_TRIM memoryTrimThreshold()
SERVER_REQUEST_TIMEOUT requestTimeout()

Приоритет — от явного к выведенному: вызов в configureServer() перекрывает переменную окружения, переменная перекрывает значение из профиля. Порядок вызовов при этом не важен — профиль подставляет своё только там, где не сказано ничего.

Как всё это отражается на модели выполнения — корутины, общее состояние, отличия от PHP-FPM — на странице Рантайм.

CORS

CORS решает, каким сторонним источникам браузер разрешит обращаться к вашему API. Winter даёт два уровня: глобальную политику для всего приложения и пер-маршрутное переопределение атрибутом #[CrossOrigin].

Что такое CORS и зачем

CORS — Cross-Origin Resource Sharing, «совместное использование ресурсов между источниками». Это механизм, которым сервер разрешает браузеру отдавать ответ коду с другого источника.

Источник (origin) — это тройка «схема + хост + порт»: https://app.example.com и https://api.example.comразные источники. По умолчанию браузеры действуют по правилу same-origin policy: JavaScript со страницы одного источника не может прочитать ответ от другого. Это защита от того, чтобы чужой сайт дёргал ваш API от имени пользователя.

Проблема. Типичная связка «SPA + API» живёт на разных источниках: фронтенд на app.example.com, API на api.example.com. Браузер по умолчанию заблокирует запрос фронтенда к API — в консоли появится знакомое CORS policy: No 'Access-Control-Allow-Origin' header.

Решение. Сервер должен явно сказать браузеру, каким источникам он доверяет — через заголовки ответа (Access-Control-Allow-Origin и родственные). Winter проставляет эти заголовки за вас: глобально для всего приложения и точечно на конкретном маршруте.

CORS — это про браузер

CORS проверяет браузер, а не сервер. Запросы из curl, Postman или бэкенд-к-бэкенду ограничение same-origin не касается — заголовки CORS на них не влияют.

Глобальная политика

Задаётся в configureCors(). CorsRegistry — текучий билдер: каждый метод принимает список значений через запятую (не массив) и возвращает сам себя, поэтому вызовы цепляются.

Метод По умолчанию Что делает
allowedOrigins(...$origins) пусто Разрешённые источники
allowedHeaders(...$headers) пусто Заголовки, которые браузеру можно прислать
exposeHeaders(...$headers) пусто Заголовки ответа, видимые из JavaScript
allowCredentials(bool) false Разрешает куки и Authorization
maxAge(int $seconds) 0 На сколько браузер закеширует preflight
vary(...$headers) пусто Дополнительные значения заголовка Vary

Не тронули ничего — CORS не отправляется вовсе. Пустой configureCors() равносилен отсутствию конфигуратора: все ответы остаются same-origin.

Как ведут себя настройки

Несколько деталей, которые видно только по итоговым заголовкам.

allowedOrigins работает в трёх режимах — в зависимости от количества:

Источников Что уходит в ответ
ни одного Access-Control-Allow-Origin: *
ровно один Этот источник, всегда и без Vary
несколько Совпавший Origin из запроса плюс Vary: Origin

Третий режим и нужен, чтобы промежуточные кеши не отдали ответ, выписанный одному источнику, другому.

allowedHeaders и maxAge уходят только в preflight. На обычных ответах их нет — они и не нужны там по спецификации. Если allowedHeaders не задан, фреймворк отражает то, что браузер запросил в Access-Control-Request-Headers.

allowCredentials() требует явных источников. Спецификация запрещает пару «* + credentials», и Winter такой заголовок просто не отправит: при пустом allowedOrigins() вызов allowCredentials() молча ничего не даст. Браузер при этом никакой ошибки не покажет — запрос с куками просто не пройдёт.

php
// ✗ заголовок Allow-Credentials не будет отправлен
$cors->allowCredentials();

// ✓ источники перечислены явно
$cors->allowedOrigins('https://app.example.com')
   ->allowCredentials();

vary() заменяет заголовок целиком. Если источников несколько, фреймворк уже поставил Vary: Origin, а ваш vary() перезапишет его. Указывайте Origin в списке сами:

php
$cors->allowedOrigins('https://app.example.com', 'https://admin.example.com')
   ->vary('Origin', 'Accept-Language');

Готовые конфигурации

Три случая, которые покрывают почти всё.

Публичный API только на чтение — доступен откуда угодно, без куки:

php
$cors->allowedHeaders('Content-Type')
   ->maxAge(86400);

SPA с авторизацией — один известный фронтенд, куки и токены разрешены:

php
$cors->allowedOrigins('https://app.example.com')
   ->allowedHeaders('Content-Type', 'Authorization')
   ->allowCredentials()
   ->maxAge(3600);

Несколько фронтендов — список источников, ответ подстраивается под запрос:

php
$cors->allowedOrigins(
       'https://app.example.com',
       'https://admin.example.com',
   )
   ->allowedHeaders('Content-Type', 'Authorization')
   ->allowCredentials()
   ->maxAge(3600);

Политика на отдельном маршруте

Когда одному эндпоинту нужна политика строже или мягче общей, на контроллер или метод вешается #[CrossOrigin]. Параметры те же шесть, только именованными аргументами:

php
use Flytachi\Winter\Kernel\Route\Annotation\CrossOrigin;

#[CrossOrigin(origins: ['https://admin.example.com'], credentials: true)]
#[RequestMapping('admin')]
class AdminController extends Controller
{
  #[GetMapping('stats')]
  public function stats(): ResponseEntity { /* admin.example.com */ }

  #[GetMapping('feed')]
  #[CrossOrigin(origins: ['https://partner.example.com'], maxAge: 3600)]
  public function feed(): ResponseEntity { /* partner.example.com */ }
}

Приоритет — от частного к общему: метод перекрывает класс, класс перекрывает глобальную политику.

Аргумент атрибута Метод билдера
origins allowedOrigins()
allowHeaders allowedHeaders()
exposeHeaders exposeHeaders()
credentials allowCredentials()
maxAge maxAge()
vary vary()

Заменяет, а не дополняет

#[CrossOrigin] полностью вытесняет глобальную политику для своего маршрута, а не сливается с ней. Если глобально разрешён заголовок Authorization, а в атрибуте указаны только origins, то на этом маршруте Authorization окажется запрещён.

Практическое правило: добавляя #[CrossOrigin], перенесите в него все нужные параметры из глобальной политики.

Preflight-запросы

Перед «непростым» запросом — с нестандартным методом, заголовком Authorization или JSON-телом — браузер сначала отправляет OPTIONS и спрашивает разрешение. Этот запрос фреймворк обрабатывает сам: отвечает 204 с нужными заголовками до middleware и контроллера. Писать метод под OPTIONS не нужно.

Чтобы ответить правильно, роутер сначала выясняет, какой маршрут собирается вызвать браузер — по заголовку Access-Control-Request-Method, — и берёт #[CrossOrigin] именно этого маршрута, если он задан.

Где заголовки появляются

Глобальные заголовки пишутся до поиска маршрута, поэтому они есть на любом ответе: успешном, 404, 405 и на ошибках 5xx.

Это сделано намеренно. Если бы на 404 заголовков не было, браузер спрятал бы от JavaScript настоящий статус и показал бы вместо него ошибку CORS — разработчик искал бы проблему в политике доступа вместо опечатки в адресе.

Дальше