search ESC

Searching…

No results for "".

Type at least 2 characters to search.

Docs
You are viewing an older version (v1.0.0-alpha.7). Go to the latest.

Broadcasting

Introduction

Magic provides a Laravel Echo-equivalent broadcasting system that lets your Flutter app receive real-time events over WebSocket connections. The Echo facade mirrors the Laravel Echo JavaScript client API — if you know how to use Laravel Echo, you already know how to use this.

The broadcasting system is:

  • Pusher-compatible: Works with Laravel Reverb, Soketi, and any Pusher-protocol server out of the box.
  • Resilient: Automatic reconnection with exponential backoff, application-level heartbeat, and event deduplication.
  • Extensible: Register custom drivers via BroadcastManager.extend().
  • Testable: Echo.fake() swaps the real driver for an in-memory fake with assertion helpers.

The BroadcastServiceProvider is not auto-registered. You must add it explicitly to your providers list.

Configuration

Copy defaultBroadcastingConfig into your application config and register BroadcastServiceProvider:

// lib/config/broadcasting.dart
import 'package:magic/magic.dart';

final Map broadcastingConfig = {
  'broadcasting': {
    'default': Env.get('BROADCAST_CONNECTION', 'null'),

    'connections': {
      'reverb': {
        'driver': 'reverb',
        'host': Env.get('REVERB_HOST', 'localhost'),
        'port': int.parse(Env.get('REVERB_PORT', '8080')!),
        'scheme': Env.get('REVERB_SCHEME', 'ws'),
        'app_key': Env.get('REVERB_APP_KEY', ''),
        'auth_endpoint': '/broadcasting/auth',
        'reconnect': true,
        'max_reconnect_delay': 30000,
        'activity_timeout': 120,
        'dedup_buffer_size': 100,
      },
      'null': {'driver': 'null'},
    },
  },
};

Register the provider in Magic.init():

await Magic.init(
  configFactories: [
    () => appConfig,
    () => broadcastingConfig,
  ],
  configs: [
    {
      'app': {
        'providers': [
          (app) => BroadcastServiceProvider(app),
        ],
      },
    },
  ],
);

Connection Options

Key Type Default Description
driver String Driver name (reverb, null, or custom)
host String 'localhost' WebSocket server hostname
port int 8080 WebSocket server port
scheme String 'ws' Connection scheme (ws or wss)
app_key String '' Reverb/Pusher application key
auth_endpoint String '/broadcasting/auth' HTTP endpoint for private/presence channel auth
reconnect bool true Whether to auto-reconnect on unexpected disconnect
max_reconnect_delay int 30000 Maximum backoff delay in milliseconds
activity_timeout int 120 Seconds before a heartbeat ping is expected
dedup_buffer_size int 100 Number of recent event fingerprints kept for deduplication

Environment Variables

BROADCAST_CONNECTION=reverb
REVERB_HOST=localhost
REVERB_PORT=8080
REVERB_SCHEME=ws
REVERB_APP_KEY=your-app-key

Echo Facade

The Echo facade provides static access to the broadcasting system, proxying all calls to the bound BroadcastManager.

API Reference

Method / Property Returns Description
Echo.channel(name) BroadcastChannel Subscribe to a public channel
Echo.private(name) BroadcastChannel Subscribe to a private channel (auth required)
Echo.join(name) BroadcastPresenceChannel Join a presence channel (auth + member tracking)
Echo.listen(channel, event, callback) BroadcastChannel Shorthand: subscribe + listen in one call
Echo.leave(name) void Unsubscribe from a channel
Echo.connect() Future Establish the WebSocket connection
Echo.disconnect() Future Close the connection and release resources
Echo.connection BroadcastDriver The resolved default driver instance
Echo.socketId String? Server-assigned socket identifier, or null when disconnected
Echo.connectionState Stream Stream of connection lifecycle state changes
Echo.onReconnect Stream Emits once each time the driver successfully reconnects
Echo.addInterceptor(interceptor) void Register an interceptor on the default connection
Echo.manager BroadcastManager The underlying manager (for extend() and advanced use)
Echo.fake() FakeBroadcastManager Swap to in-memory fake for testing
Echo.unfake() void Restore the real manager binding

Channels

Public Channels

Public channels require no authentication. Any connected client may subscribe.

// Subscribe and listen for a specific event
Echo.channel('orders').listen('OrderShipped', (event) {
  final orderId = event.data['id'];
  print('Order $orderId has shipped!');
});

// Fluent chaining for multiple events on the same channel
Echo.channel('orders')
  .listen('OrderShipped', onShipped)
  .listen('OrderCancelled', onCancelled);

// Stop listening to a specific event
Echo.channel('orders').stopListening('OrderShipped');

// Leave the channel entirely
Echo.leave('orders');

The BroadcastEvent envelope provides full context for each received message:

Property Type Description
event String Event name (e.g. 'App\\Events\\OrderShipped')
channel String Channel name the event arrived on
data Map Decoded JSON payload
receivedAt DateTime Local timestamp of receipt

Private Channels

Private channels perform an HTTP auth handshake at auth_endpoint before subscribing. The driver sends the socket_id and channel_name to your server, which validates the request and returns an auth token.

// Subscribe to a private channel (driver adds 'private-' prefix automatically)
Echo.private('user.${Auth.user()!.id}')
  .listen('ProfileUpdated', (event) {
    print('Profile updated: ${event.data}');
  });

// Convenience shorthand
Echo.listen('user.1', 'ProfileUpdated', (event) {
  print(event.data);
});

On the Laravel server side, the channel authorization lives in routes/channels.php:

Broadcast::channel('user.{id}', function ($user, $id) {
    return (int) $user->id === (int) $id;
});

Presence Channels

Presence channels extend private channels with real-time member tracking. The server returns member data on subscription success, and the driver emits onJoin/onLeave streams as membership changes.

final channel = Echo.join('room.1');

// Members currently in the channel
print('Online: ${channel.members.length}');

// React to member join/leave
channel.onJoin.listen((member) {
  print('${member['name']} joined the room');
});

channel.onLeave.listen((member) {
  print('${member['name']} left the room');
});

// Presence channels also support event listening
channel.listen('MessagePosted', (event) {
  print('New message: ${event.data['body']}');
});

BroadcastPresenceChannel API:

Property Type Description
members List> Current member list (immutable snapshot)
onJoin Stream> Emits member payload on each new join
onLeave Stream> Emits member payload on each leave

Interceptors

BroadcastInterceptor hooks into the driver message pipeline — identical in spirit to MagicNetworkInterceptor in the HTTP layer. All three hook methods have pass-through default implementations; subclass only what you need.

Method Parameters Returns Description
onSend(message) Map Map Called before an outbound message is sent. Return modified map or empty map to suppress.
onReceive(event) BroadcastEvent BroadcastEvent Called when an event arrives from the server. Return modified event to pass downstream.
onError(error) dynamic dynamic Called when the driver encounters an error. Return original to propagate or a replacement to recover.

Creating a Custom Interceptor

// lib/app/broadcasting/logging_broadcast_interceptor.dart
import 'package:magic/magic.dart';

class LoggingBroadcastInterceptor extends BroadcastInterceptor {
  @override
  BroadcastEvent onReceive(BroadcastEvent event) {
    Log.debug('Broadcast received', {
      'event': event.event,
      'channel': event.channel,
      'data': event.data,
    });
    return event;
  }

  @override
  dynamic onError(dynamic error) {
    Log.error('Broadcast error', {'error': error.toString()});
    return error;
  }
}

Registering Interceptors

Register interceptors in a Service Provider's boot() phase, after the connection is established:

class BroadcastingServiceProvider extends ServiceProvider {
  BroadcastingServiceProvider(super.app);

  @override
  Future boot() async {
    Echo.addInterceptor(LoggingBroadcastInterceptor());
  }
}

Custom Drivers

Implementing a Custom Driver

Implement the BroadcastDriver abstract class:

// lib/app/broadcasting/pusher_broadcast_driver.dart
import 'package:magic/magic.dart';

class PusherBroadcastDriver implements BroadcastDriver {
  PusherBroadcastDriver(this._config);

  final Map _config;

  @override
  Future connect() async {
    // Establish connection to Pusher.
  }

  @override
  Future disconnect() async {
    // Close the connection.
  }

  @override
  String? get socketId => /* ... */;

  @override
  bool get isConnected => /* ... */;

  @override
  Stream get connectionState => /* ... */;

  @override
  Stream get onReconnect => /* ... */;

  @override
  BroadcastChannel channel(String name) => /* ... */;

  @override
  BroadcastChannel private(String name) => /* ... */;

  @override
  BroadcastPresenceChannel join(String name) => /* ... */;

  @override
  void leave(String name) { /* ... */ }

  @override
  void addInterceptor(BroadcastInterceptor interceptor) { /* ... */ }
}

Registering the Custom Driver

Register your driver via BroadcastManager.extend() in a Service Provider's boot() phase:

class AppServiceProvider extends ServiceProvider {
  AppServiceProvider(super.app);

  @override
  Future boot() async {
    BroadcastManager.extend('pusher', (config) => PusherBroadcastDriver(config));
  }
}

Then reference the driver name in config:

'connections': {
  'pusher': {
    'driver': 'pusher',
    'app_key': Env.get('PUSHER_APP_KEY'),
    'cluster': Env.get('PUSHER_CLUSTER', 'mt1'),
  },
},

This follows the same extend() pattern used by Auth.manager.extend(...) for custom auth guards and LogManager.extend(...) for custom log drivers.

Testing

Using FakeBroadcastManager

Echo.fake() swaps the real BroadcastManager binding with an in-memory FakeBroadcastManager. All channel operations are recorded but no WebSocket connection is opened.

import 'package:flutter_test/flutter_test.dart';
import 'package:magic/magic.dart';
import 'package:magic/testing.dart';

void main() {
  MagicTest.init();

  test('subscribes to orders channel on init', () async {
    final fake = Echo.fake();

    // Exercise code under test
    final controller = OrderController();
    await controller.onInit();

    // Assert
    fake.assertSubscribed('orders');
    fake.assertConnected();
  });
}

Always call Echo.unfake() in tearDown — or use MagicTest.init() which resets the container automatically.

Assertion Helpers

FakeBroadcastManager exposes assertion methods that throw AssertionError with descriptive messages on failure:

Method Description
assertConnected() Assert the fake driver is in a connected state
assertDisconnected() Assert the fake driver is disconnected
assertSubscribed(channel) Assert a channel name is in the subscribed list
assertNotSubscribed(channel) Assert a channel name is NOT subscribed
assertInterceptorAdded() Assert at least one interceptor has been registered
reset() Clear all recorded state on the fake driver

Access the underlying FakeBroadcastDriver via fake.driver for low-level inspection:

final fake = Echo.fake();

Echo.channel('orders');
Echo.private('user.1');

expect(fake.driver.subscribedChannels, contains('orders'));
expect(fake.driver.subscribedChannels, contains('private-user.1'));
expect(fake.driver.isConnected, isFalse);

Simulate received events by publishing directly to a channel in tests:

// Inject a fake event into the channel stream
final channel = Echo.channel('orders') as _FakeBroadcastChannel;
// ... or use fake.driver to inspect subscriptions and simulate state changes

Connection

Connection Lifecycle

BroadcastConnectionState tracks the lifecycle of the WebSocket connection:

State Description
connecting Establishing the connection
connected Active, healthy connection
disconnected Not connected, not attempting reconnect
reconnecting Lost connection, attempting to re-establish

Subscribe to Echo.connectionState to react to transitions in your UI:

Echo.connectionState.listen((state) {
  switch (state) {
    case BroadcastConnectionState.connected:
      showOnlineBadge();
    case BroadcastConnectionState.reconnecting:
      showReconnectingBanner();
    case BroadcastConnectionState.disconnected:
      showOfflineBanner();
    default:
      break;
  }
});

Re-subscribe to channels after a reconnect using Echo.onReconnect:

Echo.onReconnect.listen((_) {
  Echo.channel('orders').listen('OrderShipped', onShipped);
});

Reconnection and Heartbeat

ReverbBroadcastDriver implements automatic reconnection with exponential backoff:

  • Formula: min(500ms × 2^attempt, max_reconnect_delay)
  • Default max_reconnect_delay is 30,000 ms (30 seconds)
  • Set reconnect: false in config to disable auto-reconnect

Pusher protocol error codes determine the reconnect strategy:

Code Range Action
4000–4099 Fatal — do not reconnect
4100–4199 Reconnect immediately without backoff
4200–4299 Reconnect with exponential backoff

The driver handles pusher:ping frames automatically, responding with pusher:pong to satisfy the server keepalive requirement.

Deduplication

The Reverb driver maintains a ring buffer of recently seen event fingerprints (channel + event name + raw data). Duplicate messages — which can arrive during reconnection — are silently dropped.

Configure the buffer size with dedup_buffer_size (default: 100). A larger buffer consumes more memory but reduces false duplicate detection during high-throughput scenarios.