Introduction to Server-Sent Events (SSE) in Laravel: Real-Time Notifications

- Andrés Cruz - ES En español

Video thumbnail

Server-Sent Events (SSE) are a technology based on the standard HTTP protocol that allows the server to send real-time updates to the client through an open connection. Unlike WebSockets, it does not require a specific protocol or an independent messaging server (such as Pusher or Reverb); it can be used directly on existing infrastructure.

In this architecture, the client is built with JavaScript (for example, using Vue's reactivity) and the backend is responsible for emitting the event stream (using frameworks like Laravel, Django, FastAPI, Express, or any Node.js environment).

Advantages and Limitations of SSE

To determine when to implement SSE instead of WebSockets, it is necessary to consider its main characteristics:

Advantages

  • Standard protocol: Works directly over HTTP/HTTPS without special configurations on the server.
  • Automatic reconnection: Modern browsers natively handle reconnection if the signal is interrupted.
  • Development simplicity: Simple data formatting and processing (usually in JSON format).

Limitations

  • Unidirectionality: Communication flows exclusively from the server to the client. If the client needs to send data, it must make a traditional HTTP request (POST, PUT, etc.).
  • Connection limit: Under HTTP/1.1, browsers limit concurrent connections to a maximum of 6 per domain.
  • Emission frequency: It is not designed for ultra-high frequency applications (such as real-time video games), being ideal for intervals of one second or higher.

Use Cases and Efficient Implementation

SSE is ideal for notification panels, progress bars for heavy background tasks, or live update systems. Given the limit of concurrent connections on shared servers or under HTTP/1.1, it is recommended to manage the opening and closing of the stream consciously:

  • On-demand connection: Open the connection only when the user interacts with the module (for example, when opening the notification menu or a chat interface).
  • Interval polling (Distributed polling): Activate the connection for brief periods from time to time (for example, every 5 or 10 minutes) to check for new events and close it immediately afterward.

Output Mechanism: Buffers and Data Stream in PHP

To stream data progressively without waiting for the entire script execution to complete, it is necessary to manipulate PHP's and the web server's buffering system using ob_flush() and flush().

The technical output transmission flow operates on two layers:

  1. ob_flush() (Application Level): Flushes PHP's own interpreter output buffer.
  2. flush() (Server Level): Forces the transmission of data buffered by the web server to the client's browser.

Without calling these two functions sequentially, the server would hold all messages emitted by echo statements and deliver them in a single block when the loop ends, losing the real-time behavior.

Backend Structure and Connection Control

When implementing a streaming response (for example, using response()->stream() in Laravel), you must constantly check the connection status to release resources in case the client closes the tab or loses signal.

The following flow exemplifies the event emission structure:

  • Disconnection check: The connection_aborted() function is evaluated before each iteration. If the client has disconnected, loop execution is aborted.
  • SSE message format: The SSE specification requires the basic structure event: name\ndata: { ... }\n\n.
  • Closure notification: Upon completing the heavy process or data loop, a custom event (for example, event: close) is sent to notify the client that transmission has finished.
  • Required HTTP headers: The response must include the headers Content-Type: text/event-stream, Cache-Control: no-cache, and Connection: keep-alive.

Receiving the Stream on the Client (JavaScript / Vue)

In the browser, no additional libraries are required, as the native EventSource API is used to consume the endpoint:

  1. Instantiation: An instance of EventSource('/api/v1/events') is created pointing to the API route.
  2. Listening to custom events: Listeners are registered using addEventListener('message', ...) to process incoming information and append it to the interface's reactive data structures.
  3. Closure and error management: The close event configured by the server or the error event (onerror) is listened to in order to invoke the disconnect function (eventSource.close()) and reset the interface state.

1. What is Server-Sent Events?

Server-Sent Events (SSE) is a technology that allows the Laravel server to send real-time updates to the Vue application over a single open HTTP connection.

Advantages of SSE

  • • Works over standard HTTP
  • • Does not require extra server (Pusher, Reverb…)
  • • Built-in automatic reconnection in the browser
  • • Automatic parsing of data format
  • • Compatible with all modern browsers

Limitations

  • • Unidirectional (server → client only)
  • • Maximum 6 connections per domain (HTTP/1.1)
  • • Not ideal for high frequency (<1s between events)
  • • PHP worker blocking during the stream

SSE vs WebSockets

FeatureSSEWebSockets
DirectionUnidirectionalBidirectional
ProtocolHTTP/1.1 or HTTP/2ws:// or wss://
ReconnectionAutomatic (EventSource)Manual (must implement)
InfrastructureStandard HTTPPusher, Reverb, Socket.io…
ComplexityLowMedium-High

2. Architecture Flow

┌────────────┐        GET /api/v1/events        ┌────────────────┐
│            │ ───────────────────────────────▶ │                │
│            │                                  │    Laravel     │
│   Vue 3    │    HTTP 200                      │  (PHP Worker)  │
│  Browser   │ ◀── Content-Type: text/event-... │                │
│            │                                  │                │
│            │    data: {"msg":"Notif #1"}\n\n   │  sleep(2) × 10 │
│            │    data: {"msg":"Notif #2"}\n\n   │                │
│ EventSource  data: {"msg":"Notif #3"}\n\n   │  → event:      │
│ .onmessage   ...                              │    closed      │
│            │    event: closed\n\n             │                │
│            │ ──── connection.close() ──────▶ │  flush()       │
└────────────┘                                  └────────────────┘
  1. The client (Vue) opens a standard HTTP connection to /api/v1/events.
  2. Laravel does not close the response: it sets the header Content-Type: text/event-stream.
  3. In a loop, it sends data formatted as data: {JSON}\n\n.
  4. The browser's EventSource receives each event and executes onmessage.
  5. Upon completion, the server sends an event event: closed and closes the connection.
  6. The client calls EventSource.close() to clean up the socket.

3. Backend — Laravel Controller

File: app/Http/Controllers/Api/SSEController.php

SSEController.php
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;

class SSEController extends Controller
{
    public function stream(): StreamedResponse
    {
        return response()->stream(function () {
            // Disable PHP buffering to send data instantly
            if (ob_get_level() > 0) {
                ob_end_flush();
            }
            flush();

            // Simulate sending 10 notifications
            for ($i = 1; $i <= 10; $i++) {
                if (connection_aborted()) {
                    break;
                }

                echo $this->formatEvent($i);
                flush();
                sleep(2);
            }

            // Notify client to close the connection cleanly
            echo $this->formatClosedEvent();
            flush();
        }, 200, $this->sseHeaders());
    }

    /**
     * Mandatory SSE format: data: {JSON}\n\n
     */
    public function formatEvent(int $iteration): string
    {
        $data = json_encode([
            'message' => "Notification #{$iteration}",
            'time'    => now()->toTimeString(),
        ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);

        return "data: {$data}\n\n";
    }

    /**
     * Final event to close the connection without automatic reconnection.
     */
    public function formatClosedEvent(): string
    {
        return "event: closed\ndata: {\"message\":\"Stream finished\"}\n\n";
    }

    /**
     * SSE Headers: no-cache, keep-alive, Nginx buffering off.
     */
    public function sseHeaders(): array
    {
        return [
            'Cache-Control'         => 'no-cache',
            'Content-Type'          => 'text/event-stream; charset=utf-8',
            'Connection'            => 'keep-alive',
            'X-Accel-Buffering'     => 'no',
        ];
    }
}

Key Controller Points

1

response()->stream() — Returns a StreamedResponse instead of regular JSON. The callback runs line by line without closing the HTTP socket.

2

ob_end_flush() — Disables the PHP buffer so each echo reaches the browser immediately, rather than at the end of the script.

3

connection_aborted() — If the user closes the tab, the PHP worker stops without consuming unnecessary resources.

4

X-Accel-Buffering: no — Mandatory when using Nginx (Laravel Herd uses Nginx internally). Without this, Nginx buffers the full response before sending it.

5

event: closed — Named (custom) event that lets the client know when the stream has ended and close the connection before the server closes it, avoiding unnecessary reconnection loops.

4. Backend — Route

File: routes/api.php

routes/api.php
<?php

use App\Http\Controllers\Api\SSEController;

// Server-Sent Events: unidirectional stream of real-time notifications
Route::get('/v1/events', [SSEController::class, 'stream']);

Laravel automatically prefixes /api to all routes in routes/api.php, so the final URL is GET /api/v1/events. You can verify this with php artisan route:list --path=events.

Note on connections

The route does not use auth:sanctum middleware so that it can be easily tested with a browser. In production, add authentication to secure the endpoint. Browser's EventSource automatically sends session cookies if the route is on the same domain.

5. Frontend — Vue 3 Component

File: resources/js/vue/componets/SSEComponent.vue

SSEComponent.vue
<template>
  <div>
    <div>
      <h1>
        Live Notifications (SSE)
      </h1>

      <div>
        <o-button
          v-if="!isConnected"
          iconLeft="play"
          variant="success"
          @click="connect"
        >Connect</o-button>

        <o-button
          v-else
          iconLeft="stop"
          variant="danger"
          @click="disconnect"
        >Disconnect</o-button>
      </div>

      <p v-if="isConnected">
        Connected to stream...
      </p>

      <ul v-if="messages.length > 0">
        <li
          v-for="(msg, index) in messages"
          :key="index"
        >
          <strong>[{{ msg.time }}]:</strong> {{ msg.message }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      messages: [],
      eventSource: null,
      isConnected: false,
    }
  },

  beforeUnmount() {
    this.disconnect()
  },

  methods: {
    connect() {
      // EventSource manages automatic connection and reconnection
      this.eventSource = new EventSource('/api/v1/events')

      this.eventSource.addEventListener('open', () => {
        this.isConnected = true
      })

      // Executes for each data: {JSON}\n\n
      this.eventSource.addEventListener('message', (event) => {
        this.messages.push(JSON.parse(event.data))
      })

      // Listen for custom server "closed" event
      this.eventSource.addEventListener('closed', () => {
        this.disconnect()
      })

      this.eventSource.addEventListener('error', () => {
        if (this.eventSource.readyState === 2) {
          this.isConnected = false
        }
      })
    },

    disconnect() {
      if (this.eventSource) {
        this.eventSource.close()
        this.eventSource = null
        this.isConnected = false
      }
    },
  },
}
</script>

Key Component Points

1

new EventSource() — Native browser API. Does not require external libraries. Manages the HTTP connection and automatically parses the data: ... format.

2

Automatic reconnection — If the network drops, EventSource retries connection automatically. Upon receiving event: closed, it is manually closed beforehand.

3

beforeUnmount() — Closes the socket when the component is destroyed, preventing memory leaks.

4

readyState === 2 — Equivalent to EventSource.CLOSED. We only update isConnected when the connection is truly closed (2), not while reconnecting (0).

6. Router and Navigation

Route added in router.js

import SSE from "./componets/SSEComponent.vue";

// Added to the routes list:
{
  name: 'sse',
  path:  '/vue/sse',
  component: SSE
}

Link in App.vue

<router-link
  :to="{ name: 'sse' }"
>
  SSE
</router-link>

The link is available to all users (without v-if="$root.isLoggedIn" condition).

7. Tests (Pest PHP)

File: tests/Feature/SSEControllerTest.php

SSEControllerTest.php
<?php

use App\Http\Controllers\Api\SSEController;

describe('SSEController', function () {
    describe('stream', function () {

        it('returns an SSE response with the correct headers', function () {
            $response = $this->get('/api/v1/events');

            $response->assertOk();
            expect($response->headers->get('Cache-Control'))
                ->toContain('no-cache');
            $response->assertHeader(
                'Content-Type',
                'text/event-stream; charset=utf-8'
            );
            $response->assertHeader('Connection', 'keep-alive');
            $response->assertHeader('X-Accel-Buffering', 'no');
        });

        it('streams events in the SSE format', function () {
            $controller = new SSEController;

            $event = $controller->formatEvent(1);

            expect($event)
                ->toMatch('/^data: \{"message":"Notification #1",/')
                ->toEndWith("\n\n");
        });

        it('encodes JSON without escaping accents', function () {
            $controller = new SSEController;

            $json = trim(substr(
                $controller->formatEvent(2),
                strlen('data: ')
            ));

            expect(json_decode($json, true))
                ->toMatchArray([
                    'message' => 'Notification #2',
                ]);
        });

        it('formats the closing event with the closed name', function () {
            $controller = new SSEController;

            expect($controller->formatClosedEvent())
                ->toStartWith('event: closed')
                ->toContain('"message":"Stream finished"');
        });
    });
});

Run tests:

php artisan test --compact --filter=SSEControllerTest

8. Verification with curl

To check the stream from terminal:

terminal
# View first events (~6 seconds)
curl -sN --max-time 6 http://larafirststep.test/api/v1/events

# Expected output:
data: {"message":"Notification #1","time":"13:18:42"}

data: {"message":"Notification #2","time":"13:18:44"}


# View full 10 events + closure (~24 seconds)
curl -sN --max-time 24 http://larafirststep.test/api/v1/events | tail -3

# Last lines:
event: closed
data: {"message":"Stream finished"}

The -N flag

The -N flag (equivalent to --no-buffer) tells curl to output each line as it arrives, without waiting for full server buffering.

9. Production Notes

Worker blocking

During the stream, the PHP worker remains blocked (busy) for the entire loop (sleep(2) × 10 = 20s). In production with few workers (FPM), this can saturate the server.

Solutions:

  • • Use Swoole or Octane (async server)
  • • Publish events via Redis Pub/Sub with Redis::subscribe()
  • • Use Queues to write to a file/Redis and stream reads it in real time

Rate limiting

SSE connections last much longer than regular requests. Add throttle or limit by IP/user to prevent abuse.

Authentication

EventSource automatically sends cookies if the route is on the same domain. For external APIs, use tokens in the URL or in a custom header (requires configuring withCredentials: true).

Scalability

If you need broadcasting to thousands of simultaneous clients, consider Laravel Reverb with WebSockets or services like Pusher. SSE is ideal for internal dashboards, admin notifications, or demos where concurrent volume is moderate.

10. Created or Modified Files

git status --short
 M  resources/js/vue/App.vue            ← "SSE" link in nav
 M  resources/js/vue/router.js          ← route /vue/sse
 M  routes/api.php                      ← GET /api/v1/events
??  app/Http/Controllers/Api/SSEController.php  ← SSE controller
??  resources/js/vue/componets/SSEComponent.vue ← Vue component
??  tests/Feature/SSEControllerTest.php         ← Pest tests (4)

Steps to Test

  1. Ensure Vite is running: npm run dev
  2. Open the app in your browser: http://larafirststep.test/vue/sse
  3. Click Connect
  4. Watch notifications arriving every 2 seconds
  5. Upon completion (closed event), the connection closes automatically

Stream real-time data from Laravel to Vue 3 using Server-Sent Events (SSE) without WebSockets. Includes backend and frontend code, as well as automated tests.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

I agree to receive announcements of interest about this Blog.