Holloway fires string-based events at key points in the entity persistence lifecycle. You can listen to these events to send notifications, clear caches, maintain audit logs, or trigger downstream workflows.
- Persistence Event Names
- Registering Listeners
- Preventing Operations
- Soft Delete Events
- Custom Domain Events
- Event-Driven Architecture
- Best Practices
Events are dispatched as strings in the format "eventName: FullEntityClassName". The following events fire during store() and remove() operations:
| Event | When |
|---|---|
storing |
Before create or update |
creating |
Before a new entity is inserted |
created |
After a new entity is inserted |
updating |
Before an existing entity is updated |
updated |
After an existing entity is updated |
stored |
After create or update completes |
removing |
Before an entity is removed |
removed |
After an entity is removed |
For mappers using the SoftDeletes trait, two additional events fire during restore():
| Event | When |
|---|---|
restoring |
Before a soft-deleted entity is restored |
restored |
After a soft-deleted entity is restored |
Use registerPersistenceEvent() on the mapper to register a listener for a named event. The callback receives the entity as its only argument.
class PostMapper extends Mapper
{
public function __construct()
{
parent::__construct();
$this->registerPersistenceEvent('created', function(Post $post) {
// Runs after a new post is inserted
Cache::tags(['posts'])->flush();
});
$this->registerPersistenceEvent('updated', function(Post $post) {
Cache::forget("post:{$post->getId()}");
});
$this->registerPersistenceEvent('removed', function(Post $post) {
Cache::tags(['posts', "author:{$post->getAuthorId()}"])->flush();
});
}
}You can also register listeners from a service provider or anywhere after the mapper is resolved from the container:
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
$postMapper = app(PostMapper::class);
$postMapper->registerPersistenceEvent('created', function(Post $post) {
app(AuditLogger::class)->log('post.created', ['id' => $post->getId()]);
});
}
}Return false from a storing, creating, updating, or removing listener to abort the operation. The mapper method will return false.
$this->registerPersistenceEvent('removing', function(Post $post) {
if ($post->hasActiveOrders()) {
return false; // Prevents the remove
}
});
// In calling code
if (!$postMapper->remove($post)) {
// Removal was prevented
}Returning false from stored, created, updated, or removed has no effect — the operation has already completed.
Mappers using SoftDeletes fire restoring and restored around calls to restore(). You can cancel a restore by returning false from a restoring listener.
$this->registerPersistenceEvent('restoring', function(Post $post) {
if (!$post->isEligibleForRestore()) {
return false;
}
});
$this->registerPersistenceEvent('restored', function(Post $post) {
Cache::forget("post:{$post->getId()}");
});For domain-level events — things that happen within your application logic, not just at the persistence layer — define and dispatch your own event classes. This is a Laravel pattern that Holloway doesn't need to know about.
// Define your event
class PostPublished
{
public function __construct(
public readonly Post $post,
public readonly \DateTimeInterface $publishedAt,
) {}
}
// Dispatch from your service layer
class PostService
{
public function publish(Post $post): void
{
$post->setPublishedAt(now());
$post->setStatus('published');
$this->postMapper->store($post);
event(new PostPublished($post, $post->getPublishedAt()));
}
}
// Listen in your service provider
Event::listen(PostPublished::class, function(PostPublished $event) {
app(SearchIndexer::class)->index($event->post);
app(NotificationService::class)->notifySubscribers($event->post);
});This keeps domain logic in your service/entity layer and decoupled from the mapper.
For complex workflows that span multiple services, a saga or process manager pattern works well alongside custom events:
class OrderProcessingSaga
{
public function __construct(
private OrderMapper $orderMapper,
private InventoryService $inventory,
private PaymentService $payment,
private ShippingService $shipping,
) {}
public function handleOrderCreated(OrderCreated $event): void
{
$order = $event->order;
try {
$this->inventory->reserve($order);
$order->setStatus('inventory_reserved');
$this->orderMapper->store($order);
event(new OrderInventoryReserved($order));
} catch (InsufficientInventoryException $e) {
$order->setStatus('failed_inventory');
$this->orderMapper->store($order);
event(new OrderProcessingFailed($order, 'insufficient_inventory'));
}
}
public function handleInventoryReserved(OrderInventoryReserved $event): void
{
$order = $event->order;
try {
$this->payment->charge($order);
$order->setStatus('payment_processed');
$this->orderMapper->store($order);
event(new OrderPaymentProcessed($order));
} catch (PaymentFailedException $e) {
$this->inventory->release($order);
$order->setStatus('failed_payment');
$this->orderMapper->store($order);
event(new OrderProcessingFailed($order, 'payment_failed'));
}
}
}// Good: one responsibility per listener
$this->registerPersistenceEvent('created', function(Post $post) {
Cache::tags(['posts'])->flush();
});
$this->registerPersistenceEvent('created', function(Post $post) {
app(AuditLogger::class)->log('post.created', ['id' => $post->getId()]);
});Persistence event listeners run synchronously inside the store() / remove() call. Keep them fast — queue anything that can wait.
$this->registerPersistenceEvent('created', function(Post $post) {
// Fast: clear cache inline
Cache::forget("post:{$post->getId()}");
// Slow: queue for background processing
dispatch(new UpdateSearchIndexJob($post->getId()));
dispatch(new SendPublicationNotificationsJob($post->getId()));
});A listener that throws an exception will bubble up and abort the persistence call. Wrap side effects that shouldn't block persistence.
$this->registerPersistenceEvent('created', function(Post $post) {
try {
app(SearchIndexer::class)->index($post);
} catch (SearchIndexException $e) {
logger()->error('Search index failed', ['post_id' => $post->getId()]);
dispatch(new RetrySearchIndexJob($post->getId()));
}
});Mapper persistence events are for infrastructure concerns (caching, logging, syncing). Business logic — state transitions, notifications tied to domain rules — belongs in domain events dispatched from your service layer.
// Infrastructure concern: belongs in persistence event
$this->registerPersistenceEvent('removed', fn($post) => Cache::forget("post:{$post->getId()}"));
// Business concern: belongs in service layer as a domain event
class PostService
{
public function archive(Post $post): void
{
$post->archive();
$this->postMapper->store($post);
event(new PostArchived($post)); // domain event, not persistence event
}
}