garlic/event-source
garlic/event-source is a Laravel/PHP event sourcing package for persisting domain changes as immutable events, rebuilding aggregates, and projecting read models. It helps teams add auditability and decoupled workflows to their applications.
Before writing a line of code, do a five-minute reality check on this one. The repository is a Symfony bundle (event-source-bundle) from 2017 with zero stars and zero dependents, so you are not adopting a supported library — you are adopting ~200 lines of source you will likely have to own. That is still fine for SSE, because SSE is a tiny protocol (a text/event-stream response with data:, event:, id:, retry: lines and a blank-line terminator). Just budget for reading the source rather than the README.
Step 1 — install and inspect.
composer require garlic/event-source
ls vendor/garlic/event-source/src
Read src/ in this order:
Response/EventSourceResponse.php or similar, extending Symfony\Component\HttpFoundation\StreamedResponse) — this is the whole library.id:/event:/data: and writes to php://output).DependencyInjection/ + EventSourceBundle.php — you can ignore these entirely in Laravel; you are not registering a Symfony bundle.composer.json — check the php and symfony/http-foundation constraints. Expect something like php: ^7.0 and Symfony 2/3/4. On PHP 8.2+ this will usually still install (open constraints) but verify with composer why-not symfony/http-foundation.Step 2 — first request/response round trip. Don't start with a real feature. Wire a throwaway route that emits three events and closes:
// routes/web.php
use Garlic\EventSourceBundle\Response\EventSourceResponse; // verify exact namespace
Route::get('/_sse/ping', function () {
return new EventSourceResponse(function () {
for ($i = 1; $i <= 3; $i++) {
echo "id: {$i}\n";
echo "event: ping\n";
echo 'data: '.json_encode(['n' => $i])."\n\n";
@ob_flush(); flush();
sleep(1);
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]);
});
Hit it with curl -N http://localhost:8000/_sse/ping before touching a browser. If curl shows events one per second, your PHP/web-server stack is fine and any later problem is your app code. If curl dumps everything at the end, stop and fix buffering first (see Gotchas).
First real use case: a per-user notification or job-progress feed. Pick it because it is write-light, has an obvious polling fallback, and doesn't require you to solve fan-out across workers yet.
Treat the response class as a thin writer, not a framework. The idiomatic shape is: build the EventSourceResponse from a closure that loops, and let the closure own all the domain logic. Keep the loop boring.
Pattern: cursor-based drain loop. The most common production shape is "reconnect-safe, id-tracked, heartbeat every N seconds":
Route::get('/stream/{channel}', function (string $channel, Request $request) {
$lastId = (int) ($request->header('Last-Event-ID') ?? $request->query('last_id', 0));
$deadline = microtime(true) + 55; // under proxy idle timeouts
return new EventSourceResponse(function () use ($channel, $lastId, $deadline) {
$cursor = $lastId;
$lastBeat = microtime(true);
while (!connection_aborted() && microtime(true) < $deadline) {
$rows = DB::table('events')
->where('channel', $channel)
->where('id', '>', $cursor)
->orderBy('id')
->limit(50)
->get();
foreach ($rows as $row) {
$cursor = $row->id;
echo "id: {$row->id}\n";
echo "event: {$row->type}\n";
echo 'data: '.$row->payload."\n\n";
}
if (microtime(true) - $lastBeat > 15) {
echo ": keep-alive\n\n"; // comment line = heartbeat, ignored by EventSource
$lastBeat = microtime(true);
}
@ob_flush(); flush();
if ($rows->isEmpty()) { usleep(500_000); }
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache, no-transform',
'Connection' => 'keep-alive',
'X-Accel-Buffering' => 'no',
]);
})->middleware('auth');
Key details: id: must be monotonic so the browser's automatic Last-Event-ID header works on reconnect; event: names let the frontend use addEventListener instead of a switch; a self-imposed $deadline is what lets the browser reconnect cleanly instead of dying on a proxy timeout.
Pattern: emit-then-poll vs. pub/sub. With this package you only get the "poll from a store" half. If you want real fan-out, keep the SSE endpoint as the delivery mechanism and put a Redis pub/sub subscriber (predis pubSubLoop) or a SELECT ... FOR UPDATE SKIP LOCKED queue drain inside the closure. Do not try to make the bundle do pub/sub — it doesn't.
Pattern: frontend contract. Standardize early, because you cannot easily version an open stream:
const es = new EventSource('/stream/orders');
es.addEventListener('order.created', e => handle(JSON.parse(e.data)));
es.onerror = () => { /* EventSource auto-reconnects; only surface if readyState === CLOSED */ };
Add es.close() on beforeunload so you don't leak PHP workers on tab churn.
Pattern: keep a polling fallback behind a flag. One config key (SSE_ENABLED) that flips the frontend to setInterval(fetch(...)) buys you an escape hatch when a customer's proxy buffers everything.
Integration tips. Put the emitter behind your own small interface (App\Support\Sse\Stream) wrapping the package class, so the one day you swap it out for symfony/http-foundation's StreamedResponse directly, no controllers change. Register it as a singleton binding in a service provider. For long-running work, prefer "job writes rows to a table, SSE tails the table" over "SSE runs the job" — it survives disconnects.
Output buffering is the #1 failure. SSE dies silently if anything holds the buffer. You need ob_end_flush()/@ob_flush() + flush() after each event, output_buffering = Off in the FPM pool for this route, zlib.output_compression = Off, and proxy_buffering off / X-Accel-Buffering: no in nginx. Also strip Content-Encoding: gzip — compression defeats incremental delivery.
Laravel middleware will break the stream. StartSession holds the session file lock and serializes the whole loop; call session()->save() (or session_write_close()) before the loop, or exclude the route from session middleware. The debugbar and Barryvdh\Debugbar inject HTML/output at terminate time — disable debugbar for the streaming route. Likewise avoid any middleware that buffers the response (response caching, ETag, CompressResponse).
PHP-FPM workers are the real cost. Each open SSE connection pins one FPM worker for up to your deadline. Ten open tabs on a pm.max_children = 10 pool takes the site down. Rules of thumb: keep the deadline short (30–60s), set max_execution_time generously but finite, ignore_user_abort(false), and do not scale SSE on the same pool as your web traffic. This is the single biggest architectural gotcha with any PHP SSE library.
Proxy idle timeouts. nginx defaults to 60s proxy_read_timeout, ELB/ALB ~60s, some CDNs 30s. Send a heartbeat comment (: ping\n\n) every 10–15s and set retry: 3000 once at stream start so the browser doesn't hammer you with 1s reconnects.
Package age and maintenance. No release since 2017-09-07. Expect Symfony 2/3-era type hints, no declare(strict_types=1), possibly each()-era code or deprecated Symfony\Component\HttpFoundation\StreamedResponse constructor signature use. Check git log, and run php -l plus a smoke test on your PHP version before integrating. Realistically you will end up vendoring the ~100 lines you need — new StreamedResponse($callback) plus a data: formatter is not worth a supply-chain risk on an unmaintained repo. Do the vendor-or-vend decision consciously rather than by default.
Testing. You cannot easily PHPUnit a streamed response body; $response->getContent() returns false for StreamedResponse. Capture via ob_start() around $response->sendContent() and assert the raw frames (id:, event:, blank line terminators). Test the closure's logic by extracting it into a class you can unit test, and keep the response class as a pure adapter.
Extension points. Subclass the response class to override the framing method (e.g., add a : heartbeat writer or custom event envelope); do not fork the whole package. If the emitter writes to php://output directly, wrapping it in an interface is your only realistic seam for adding metrics (events sent, connection duration) — which you will want, because silent SSE failures are invisible in logs.
Alternatives worth comparing before committing: symfony/mercure (Mercure hub, proper pub/sub, survives worker limits), laravel/streamed-response-style helpers, or ~30 lines of hand-rolled code. For a 0-star, 8-year-old bundle, hand-rolling is a legitimate answer — but if you do adopt it, keep it behind your own interface so that decision stays cheap to reverse.
How can I help you explore Laravel packages today?