Tina4

Chapter 13: Events#

1. Decouple Your Code#

A user registers. You need to send a welcome email, create a default profile, assign a free trial, and log the event. Without events, your registration handler does all of it directly. It grows. It imports five different services. It becomes impossible to test in isolation.

Events invert that. The registration handler emits one event: user.registered. Five listeners respond to it independently. Each listener is small, single-purpose, and testable on its own. Adding a sixth action means adding one new listener. No changes to the registration handler.

Tina4 has a built-in events system. No external message broker. No configuration. Zero dependencies.


2. Listening for Events#

Register a listener with Events::on(). The listener fires every time the event is emitted.

php
<?phpuse Tina4\Events;โ€‹Events::on('user.registered', function (array $data): void {    echo "Send welcome email to: {$data['email']}\n";});โ€‹Events::on('user.registered', function (array $data): void {    echo "Create default profile for user {$data['id']}\n";});

Multiple listeners on the same event all run. Registration order is the default execution order.


3. Emitting Events#

Emit an event with Events::emit(). Every registered listener for that event fires synchronously in registration order.

php
<?phpuse Tina4\Events;โ€‹// Register listeners (usually done at app boot)Events::on('user.registered', function (array $data): void {    echo "Welcome email -> {$data['email']}\n";});โ€‹Events::on('user.registered', function (array $data): void {    echo "Default profile -> user {$data['id']}\n";});โ€‹Events::on('user.registered', function (array $data): void {    echo "Free trial started for {$data['name']}\n";});โ€‹// Emit the event (usually done inside a route handler or service)Events::emit('user.registered', [    'id'    => 42,    'name'  => 'Alice',    'email' => 'alice@example.com',    'plan'  => 'free']);

Output:

Welcome email -> alice@example.comDefault profile -> user 42Free trial started for Alice

All three listeners ran. The emitter does not know they exist.


4. One-Shot Listeners with once()#

Events::once() registers a listener that fires exactly once, then removes itself.

php
<?phpuse Tina4\Events;โ€‹// This listener fires on the first emit onlyEvents::once('app.boot', function (): void {    echo "Database connection pool initialized\n";});โ€‹Events::emit('app.boot');   // Fires: "Database connection pool initialized"Events::emit('app.boot');   // Nothing -- listener was already removedEvents::emit('app.boot');   // Nothing

Useful for one-time initialization, cache warmup, or any setup that must run exactly once regardless of how many times the event fires.


5. Removing Listeners with off()#

Events::off() removes a specific listener or all listeners for an event.

php
<?phpuse Tina4\Events;โ€‹$auditListener = function (array $data): void {    echo "Audit log: order {$data['id']} placed\n";};โ€‹// RegisterEvents::on('order.placed', $auditListener);โ€‹// WorksEvents::emit('order.placed', ['id' => 101]);// Output: Audit log: order 101 placedโ€‹// Remove this specific listenerEvents::off('order.placed', $auditListener);โ€‹// Listener no longer firesEvents::emit('order.placed', ['id' => 102]);// Output: (nothing)

Remove all listeners for an event:

php
Events::off('order.placed');

After this call, Events::emit('order.placed', ...) fires nothing.


6. Priority#

Listeners with a higher priority run before those with lower priority. Default priority is 0. Pass priority as the third argument to Events::on().

php
<?phpuse Tina4\Events;โ€‹Events::on('payment.received', function (array $data): void {    echo "3. Send receipt email\n";}, 0);   // Priority 0 -- runs thirdโ€‹Events::on('payment.received', function (array $data): void {    echo "1. Record payment in ledger\n";}, 10);  // Priority 10 -- runs firstโ€‹Events::on('payment.received', function (array $data): void {    echo "2. Update subscription status\n";}, 5);   // Priority 5 -- runs secondโ€‹Events::emit('payment.received', ['amount' => 99.00, 'currency' => 'USD']);

Output:

1. Record payment in ledger2. Update subscription status3. Send receipt email

Higher number = higher priority = runs earlier.


7. Events in Route Handlers#

The typical pattern: emit events from route handlers, define listeners in a separate boot file.

src/boot/events.php - register all listeners at startup:

php
<?phpuse Tina4\Events;โ€‹// User eventsEvents::on('user.registered', function (array $data): void {    // Send welcome email via Messenger    \Tina4\Messenger::send(        to: $data['email'],        subject: 'Welcome to the app!',        body: "Hi {$data['name']}, your account is ready."    );});โ€‹Events::on('user.registered', function (array $data): void {    // Queue a follow-up drip email for day 3    $queue = new \Tina4\Queue(topic: 'drip-emails');    $queue->push([        'user_id'   => $data['id'],        'template'  => 'day3-followup',        'send_at'   => time() + (3 * 86400)    ]);});โ€‹// Order eventsEvents::on('order.completed', function (array $data): void {    error_log("[order] completed: #{$data['id']} total={$data['total']}");});

src/routes/users.php - emit from the route handler:

php
<?phpuse Tina4\Router;use Tina4\Events;โ€‹Router::post('/api/users/register', function ($request, $response) {    $body = $request->body;โ€‹    if (empty($body['email']) || empty($body['name'])) {        return $response->json(['error' => 'email and name are required'], 400);    }โ€‹    // Create user (simplified)    $user = [        'id'    => rand(1000, 9999),        'name'  => $body['name'],        'email' => $body['email'],        'plan'  => $body['plan'] ?? 'free'    ];โ€‹    // Emit - listeners handle the rest    Events::emit('user.registered', $user);โ€‹    return $response->json([        'message' => 'Registration successful',        'user_id' => $user['id']    ], 201);});
bash
curl -X POST http://localhost:7145/api/users/register \  -H "Content-Type: application/json" \  -d '{"name": "Alice", "email": "alice@example.com"}'
json
{  "message": "Registration successful",  "user_id": 4721}

The route handler responds immediately. The listeners handle the rest.


8. Checking Registered Listeners#

Inspect which events have listeners (useful in tests and during development):

php
<?phpuse Tina4\Events;โ€‹Events::on('order.placed', function (): void {});Events::on('order.placed', function (): void {});Events::on('payment.failed', function (): void {});โ€‹$count = count(Events::listeners('order.placed')); // 2$names = Events::events();                          // ['order.placed', 'payment.failed']$has   = Events::listeners('order.placed') !== []; // true

In tests, reset all events between test cases:

php
Events::clear();

9. Exercise: Order Lifecycle Events#

Build an order system where each state change emits an event and multiple listeners respond.

Requirements#

  1. Define listeners for: order.created, order.paid, order.shipped
  2. Create these endpoints:
MethodPathDescription
POST/api/ordersCreate order, emit order.created
POST/api/orders/{id}/payMark paid, emit order.paid
POST/api/orders/{id}/shipMark shipped, emit order.shipped
  1. Each event should have at least two listeners (e.g., logging + notification)

Test with:#

bash
curl -X POST http://localhost:7145/api/orders \  -H "Content-Type: application/json" \  -d '{"customer": "Alice", "items": ["Widget A", "Widget B"], "total": 59.98}'โ€‹curl -X POST http://localhost:7145/api/orders/1001/paycurl -X POST http://localhost:7145/api/orders/1001/ship

10. Solution#

src/boot/order-events.php:

php
<?phpuse Tina4\Events;โ€‹Events::on('order.created', function (array $order): void {    error_log("[order] created #{$order['id']} for {$order['customer']}");}, 10);โ€‹Events::on('order.created', function (array $order): void {    // Notify warehouse    error_log("[warehouse] new order #{$order['id']} -> " . implode(', ', $order['items']));}, 5);โ€‹Events::on('order.paid', function (array $order): void {    error_log("[payment] received for order #{$order['id']} - \${$order['total']}");}, 10);โ€‹Events::on('order.paid', function (array $order): void {    // Trigger fulfillment    error_log("[fulfillment] queue pick-and-pack for order #{$order['id']}");}, 5);โ€‹Events::on('order.shipped', function (array $order): void {    error_log("[shipping] order #{$order['id']} dispatched -> {$order['customer']}");}, 10);โ€‹Events::on('order.shipped', function (array $order): void {    // Send tracking email    error_log("[email] tracking notification sent for order #{$order['id']}");}, 5);

src/routes/orders.php:

php
<?phpuse Tina4\Router;use Tina4\Events;โ€‹$orders = [];โ€‹Router::post('/api/orders', function ($request, $response) use (&$orders) {    $body = $request->body;    $order = [        'id'       => rand(1000, 9999),        'customer' => $body['customer'] ?? 'Unknown',        'items'    => $body['items'] ?? [],        'total'    => $body['total'] ?? 0,        'status'   => 'created'    ];    $orders[$order['id']] = $order;    Events::emit('order.created', $order);    return $response->json(['message' => 'Order created', 'order' => $order], 201);});โ€‹Router::post('/api/orders/{id:int}/pay', function ($request, $response) use (&$orders) {    $id = $request->params['id'];    if (!isset($orders[$id])) {        return $response->json(['error' => 'Order not found'], 404);    }    $orders[$id]['status'] = 'paid';    Events::emit('order.paid', $orders[$id]);    return $response->json(['message' => 'Order marked as paid', 'order' => $orders[$id]]);});โ€‹Router::post('/api/orders/{id:int}/ship', function ($request, $response) use (&$orders) {    $id = $request->params['id'];    if (!isset($orders[$id])) {        return $response->json(['error' => 'Order not found'], 404);    }    $orders[$id]['status'] = 'shipped';    Events::emit('order.shipped', $orders[$id]);    return $response->json(['message' => 'Order shipped', 'order' => $orders[$id]]);});

11. Gotchas#

1. Listeners fire synchronously#

Problem: A slow listener blocks the HTTP response.

Cause: Events::emit() runs all listeners in the same thread before returning. A listener that takes 2 seconds delays the response by 2 seconds.

Fix: For slow work (emails, PDF generation), push to a queue inside the listener instead of doing the work inline.

2. Uncaught exceptions in listeners crash the emit#

Problem: One bad listener prevents the remaining listeners from running.

Cause: An exception propagates up from the listener and aborts the emit loop.

Fix: Wrap listener bodies in try/catch, or register a global error handler on the event system. At minimum, log the error and continue.

3. Forgetting to reset in tests#

Problem: Tests interfere with each other because a listener registered in test A fires during test B.

Cause: Events are global state. Listeners accumulate across tests.

Fix: Call Events::clear() in your test setUp or tearDown method.

4. Using closures with off()#

Problem: Events::off('event', $listener) does not remove the listener.

Cause: You registered an anonymous closure inline and are trying to pass a different closure reference to off().

Fix: Assign the closure to a named variable before registering it, then pass that same variable to off().