SSE і gRPC у Laravel
TrueAsyncServer усередині laravel-spawn побудований просто: він приймає HttpRequest, збирає з нього Illuminate\Http\Request, проганяє його через Kernel::handle(), отримує Illuminate\Http\Response, буферизує його цілком у HttpResponse і відправляє. Один запит, одна відповідь, увесь вміст одразу. Саме те, до чого Laravel звикав усю свою історію.
Але друга серія на цьому сайті мала індикатор прогресу через SSE і gRPC на тому самому порту, і обидва живуть не за формулою "одна відповідь", а за формулою "потік повідомлень". Буферизований Illuminate\Response під це не пристосований: у нього немає ні sseEvent(), ні writeMessage(). Тож контролеру потрібен спосіб дістатися справжнього, сирого HttpResponse, оминаючи буфер.
Сира відповідь на вимогу
laravel-spawn кладе HttpRequest і HttpResponse поточного запиту в request_context() ще до того, як передасть керування маршрутизатору Laravel, той самий трюк, яким сам Laravel користується для auth і session, ще в першому розділі. Дістати їх можна двома функціями:
trueasync_request(); // TrueAsync\HttpRequest
trueasync_response(); // TrueAsync\HttpResponseЩойно контролер сам записав у сиру відповідь і сам закрив її ($res->end()), звичайний шлях Illuminate\Response вже не потрібен: TrueAsyncServer перевіряє isClosed() перед буферизацією, і якщо відповідь уже надіслано вручну, зверху нічого не додається. Контролер усе одно зобов'язаний щось повернути, Laravel цього вимагає, але вміст цього значення вже нікого не цікавить.
SSE: індикатор прогресу всередині маршруту Laravel
Діставатися до sseStart()/sseEvent()/sseComment() через trueasync_response() щоразу незручно, тож пакет постачає тонку обгортку:
use Spawn\Laravel\Sse\Sse;
use function Async\delay;
Route::get('/import/progress', function () {
Sse::start(retryMs: 3000);
$import = currentImport();
while (!$import->isFinished()) {
Sse::event(
data: json_encode(['done' => $import->counter(), 'total' => $import->total()]),
event: 'progress',
);
if (!Sse::connected()) {
break; // вкладку закрили
}
delay(1000);
}
Sse::event(event: 'finished', data: 'ok');
Sse::end();
return response()->noContent();
});Впізнаєте цей код? Це той самий індикатор прогресу з шостого розділу серверної серії, слово в слово, тільки $res->sseEvent() замінено на Sse::event(). Усередині маршруту вам і далі доступні Auth::user(), session(), Eloquent, це звичайний обробник Laravel, він просто відповідає потоком замість одноразової відповіді. Middleware, авторизація через Route::middleware('auth:sanctum'), current_context() для стану, прив'язаного до запиту, з першого розділу, усе це працює як завжди, тому що Kernel::handle() навколо цього коду не змінився ні на рядок.
Одна деталь налаштування стосується не Laravel, а самого сервера: довготривалий потік не повинен обриватися через таймаут запису, потрібний звичайним відповідям, тож сервіси з потоками вимикають його глобально, ASYNC_WRITE_TIMEOUT=0 у .env, той самий важіль, що й у розділі про продакшен серверної серії.
gRPC: контракт замість маршруту
З gRPC компроміс жорсткіший. Протокол спілкується protobuf-закодованими повідомленнями, які не мають осмисленого відображення ні на Illuminate\Http\Request, ні тим паче на Response. Фабрикувати фейковий HTTP-запит лише для того, щоб протягнути його через маршрутизацію і middleware Laravel, було б безглуздо: клієнт gRPC не надсилає ні кук, ні CSRF-токена, ні тіла форми. Тож gRPC у laravel-spawn цілком оминає Kernel, ідучи окремим шляхом, налаштованим через конфігураційний файл:
// config/async.php
'grpc_handlers' => [
'/profile.ProfileService/GetProfile' => [
\App\Grpc\ProfileServiceHandler::class, 'getProfile',
],
],Сам обробник резолвиться через контейнер (тож звичайний DI через конструктор і далі працює), а сигнатура методу та сама пара сирих об'єктів, яку ви вже бачили в десятому розділі серверної серії:
namespace App\Grpc;
use Profile\GetProfileRequest;
use Profile\Profile;
use TrueAsync\HttpRequest;
use TrueAsync\HttpResponse;
class ProfileServiceHandler
{
public function __construct(private readonly UserRepository $users) {}
public function getProfile(HttpRequest $req, HttpResponse $res): void
{
$getProfile = new GetProfileRequest();
$getProfile->mergeFromString($req->readMessage());
$user = $this->users->find($getProfile->getUserId());
$profile = (new Profile())
->setId($user->id)
->setName($user->name)
->setRegion($user->region);
$res->writeMessage($profile->serializeToString());
// звичайний return: сервер сам додає grpc-status: 0 (OK)
}
}$this->users прийшов через конструктор, як і в будь-якому сервісі Laravel: контейнер і DI усе так само повністю в грі, хоча маршрутизація Laravel узагалі не торкалася цього шляху. Помилки подорожують так само, як у голому TrueAsync Server: через трейлер grpc-status, а не HTTP-код стану.
public function getProfile(HttpRequest $req, HttpResponse $res): void
{
if (!$this->authorized($req)) {
$res->setTrailer('grpc-status', '7'); // PERMISSION_DENIED
return;
}
// ...
}Виняток, кинутий з обробника, сам laravel-spawn перетворює на grpc-status: 13 (INTERNAL), таку саму поведінку мав голий сервер, просто тепер вона захована всередині адаптера.
Що тут справжнє, а що ні
Жоден із цих шляхів не є емуляцією чи хаком поверх Symfony StreamedResponse, обидва це прямий доступ до тих самих примітивів HttpRequest/HttpResponse із серверної серії, прямо з-під маршруту Laravel. Ціна передбачувана: обробник SSE не відповідає через звичне return response()->json(...), він сам пише і сам вирішує, коли закрити з'єднання; обробник gRPC узагалі не бачить middleware чи маршрутизації Laravel, лише контейнер для DI. Тут немає жодної чорної магії, але немає й вдавання: якщо вам потрібен справжній потік, доведеться вийти з затишного світу буферизованого Response туди, де живе сам сервер.
Ми розглянули, що Laravel уміє: звичайні запити, потоки, gRPC. Лишилося те, чого Laravel не вміє сам, і що варте пильної уваги у вашому власному коді й чужому, перш ніж віддавати його конкурентним корутинам. Саме туди ми й прямуємо в наступному розділі.