This example publishes a user notification and updates the page without a full reload. It uses the default Redis adapter. For the dedicated Hub deployment, follow Mercure Hub; publishing and channel policy code remain the same.
Add the development connection to app/Config/Sse.php:
public string $channelPrefix = 'example:sse:';
public array $redis = [
'host' => '127.0.0.1',
'port' => 6379,
'database' => 0,
];Verify it:
php spark sse:health-checkThe browser will request a logical channel such as users.42. Private
channels are denied by default, so the application must decide whether the
current user may subscribe.
Implement ChannelAuthorizerInterface:
<?php
declare(strict_types=1);
namespace App\Sse;
use Maniaba\CodeIgniterSse\Contracts\ChannelAuthorizerInterface;
final class ChannelAuthorizer implements ChannelAuthorizerInterface
{
public function authorize(?object $user, string $channel): bool
{
if (
$user !== null
&& preg_match('/^users\.(\d+)$/', $channel, $matches) === 1
) {
return (string) $user->id === $matches[1];
}
return str_starts_with($channel, 'public.');
}
}Implement UserResolverInterface for the application's authentication layer:
<?php
declare(strict_types=1);
namespace App\Sse;
use Maniaba\CodeIgniterSse\Contracts\UserResolverInterface;
final class UserResolver implements UserResolverInterface
{
public function resolve(): ?object
{
return service('auth')->user();
}
}Select these implementations in app/Config/Sse.php:
<?php
declare(strict_types=1);
namespace Config;
use App\Sse\ChannelAuthorizer;
use App\Sse\UserResolver;
use Maniaba\CodeIgniterSse\Config\Sse as BaseSse;
final class Sse extends BaseSse
{
public string $channelAuthorizer = ChannelAuthorizer::class;
public string $userResolver = UserResolver::class;
}Publish only after the domain operation has succeeded:
$order->markAsPaid();
sse()->publish(
"users.{$order->user_id}",
'notification.created',
[
'title' => 'Order paid',
'orderId' => $order->id,
],
);The publisher builds a versioned envelope and sends it to the prefixed Redis channel. Application code does not build Redis channel names and does not write SSE frames.
If the application already has an event object, implement
PublishableEventInterface and call sse()->publish($object). The object
provides the channel, event name or event object, and payload.
import {
RedisSseAdapter,
SseClient,
} from '/vendor/codeigniter4-sse/sse-client.js';
const live = new SseClient({
endpoint: '/sse',
adapter: new RedisSseAdapter(),
channels: [`users.${currentUserId}`],
withCredentials: true,
});
live.on('notification.created', ({ data }) => {
showToast(data.title);
refreshOrder(data.orderId);
});
live.on('status', ({ status }) => {
document.querySelector('[data-live-status]').textContent = status;
});
live.connect();With the default Redis broker, the browser opens:
GET /sse?channels=users.42
Accept: text/event-streamSession cookies are sent when allowed by the browser and CORS configuration.
Standard browser EventSource does not support arbitrary Authorization
headers.
Most full-page applications can let navigation close the connection. A single-page application should close streams owned by an unmounted view:
live.close();Calling connect() later opens a new EventSource with the existing
listeners.
Send domain facts:
sse()->publish(
"orders.{$order->id}",
'order.updated',
[
'orderId' => $order->id,
'status' => $order->status,
],
);Let the frontend choose the DOM update:
live.on('order.updated', ({ data }) => {
const target = document.querySelector(
`[data-order-status="${data.orderId}"]`,
);
if (target !== null) {
target.textContent = data.status;
}
});Sending HTML selectors and fragments as the primary event format couples the backend to one page structure and is harder to reuse safely.