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.
<?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.
<?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 AliceAll 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.
<?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'); // NothingUseful 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.
<?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:
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().
<?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 emailHigher 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:
<?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:
<?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);});curl -X POST http://localhost:7145/api/users/register \ -H "Content-Type: application/json" \ -d '{"name": "Alice", "email": "alice@example.com"}'{ "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):
<?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') !== []; // trueIn tests, reset all events between test cases:
Events::clear();9. Exercise: Order Lifecycle Events#
Build an order system where each state change emits an event and multiple listeners respond.
Requirements#
- Define listeners for:
order.created,order.paid,order.shipped - Create these endpoints:
| Method | Path | Description |
|---|---|---|
POST | /api/orders | Create order, emit order.created |
POST | /api/orders/{id}/pay | Mark paid, emit order.paid |
POST | /api/orders/{id}/ship | Mark shipped, emit order.shipped |
- Each event should have at least two listeners (e.g., logging + notification)
Test with:#
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/ship10. Solution#
src/boot/order-events.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:
<?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().