ahmedebead / laravel-hooks
A standalone, framework-agnostic hook system for Laravel applications. Attach lifecycle callbacks (before/after/error) to any service method with sync, queued, delayed, batched, and conditional execution strategies.
Requires
- php: ^8.2|^8.3
- laravel/framework: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- pestphp/pest: ^2.0|^3.0
- pestphp/pest-plugin-laravel: ^2.0|^3.0
- phpstan/phpstan: ^1.10|^2.0
README
Laravel Hooks
Add before, after, and error lifecycle hooks to any method in any class — controllers, services, jobs, models, or plain PHP objects.
No event system boilerplate. No observers. Just attach a hook to a method and it runs automatically.
Requirements
- PHP 8.2+
- Laravel 10, 11, 12, or 13
Installation
composer require ahmedebead/laravel-hooks
Laravel auto-discovers the package. To publish the config:
php artisan vendor:publish --tag=laravel-hooks-config
Quick Start
1. Add the trait to any class
use Ahmed3bead\LaravelHooks\HookableTrait; class OrderService { use HookableTrait; public function create(array $data): Order { return $this->executeWithHooks('create', function () use ($data) { return Order::create($data); }); } }
2. Create a hook
php artisan make:hook OrderCreated --type=audit --phase=after --sync
Or manually:
use Ahmed3bead\LaravelHooks\BaseHookJob; use Ahmed3bead\LaravelHooks\HookContext; class OrderCreatedHook extends BaseHookJob { public function handle(HookContext $context): void { $order = $context->getModelFromResult(); AuditLog::create([ 'action' => 'order.created', 'order_id' => $order?->id, 'user_id' => $context->getUserId(), ]); } }
3. Register the hook
From inside the class — registerHooks() is called automatically when the class first uses hooks:
class OrderService { use HookableTrait; protected function registerHooks(): void { $this->afterHook('create', OrderCreatedHook::class); } }
From a service provider — register hooks for any class from anywhere:
use Ahmed3bead\LaravelHooks\HookManager; class AppServiceProvider extends ServiceProvider { public function boot(HookManager $hooks): void { $hooks->addSyncHook(OrderService::class, 'create', 'after', OrderCreatedHook::class); } }
Inline, anywhere — fluent API on any instance:
$orderService->afterHook('create', OrderCreatedHook::class);
Works With Any Class
HookableTrait is not limited to services:
class UserController { use HookableTrait; } class ProcessOrderJob { use HookableTrait; } class DataImporter { use HookableTrait; } class User extends Model { use HookableTrait; }
Execution Phases
| Phase | When it fires |
|---|---|
before |
Before the method body runs |
after |
After the method returns |
error |
When the method throws — hook fires, exception re-thrown |
Registering Hooks
From inside the class (public API)
// Phase shortcuts — always synchronous $this->beforeHook('create', MyHook::class); $this->afterHook('create', MyHook::class); $this->errorHook('create', MyHook::class); // Explicit sync — choose any phase $this->syncHook('before', 'create', MyHook::class); $this->syncHook('after', 'create', MyHook::class); $this->syncHook('error', 'create', MyHook::class); // Inline closure — no separate class needed $this->syncHookWithLogic('after', 'create', function (HookContext $ctx) { Log::info('Created', ['user' => $ctx->getUserId()]); }); // Any strategy $this->hook('after', 'create', MyHook::class, strategy: 'queue'); $this->hook('after', 'create', MyHook::class, strategy: 'delay', options: ['delay' => 300]); // Inline closure (sync only — closures cannot be serialized for async strategies) $this->hookWithLogic('after', 'create', function (HookContext $ctx) { Log::info('Order created', ['id' => $ctx->getModelId()]); });
Note: Closure hooks (
hookWithLogic/syncHookWithLogic) only support thesyncstrategy. Closures cannot be serialized for queue, delay, or batch execution. For async strategies, create a named hook class implementingHookJobInterface.
From outside the class (HookManager)
$hooks = app(HookManager::class); $hooks->addSyncHook(OrderService::class, 'create', 'after', AuditHook::class); $hooks->addQueuedHook(OrderService::class, 'create', 'after', EmailHook::class); $hooks->addDelayedHook(OrderService::class, 'create', 'after', FollowUpHook::class, delay: 3600); $hooks->addBatchedHook(OrderService::class, 'index', 'after', AnalyticsHook::class); // Register multiple at once $hooks->addHooks([ ['target' => OrderService::class, 'method' => 'create', 'phase' => 'after', 'hook' => AuditHook::class], ['target' => OrderService::class, 'method' => 'update', 'phase' => 'after', 'hook' => AuditHook::class], ]); // Global hook — fires for every class using HookableTrait $hooks->addGlobalHook('create', 'after', GlobalAuditHook::class);
Execution Strategies
Sync (default)
Runs immediately in the same request.
$this->afterHook('create', AuditHook::class); // or $hooks->addSyncHook(OrderService::class, 'create', 'after', AuditHook::class);
Queued
Pushed to a Laravel queue and processed in the background.
$this->hook('after', 'create', EmailHook::class, strategy: 'queue'); // or $hooks->addQueuedHook(OrderService::class, 'create', 'after', EmailHook::class);
Delayed
Queued with a delay (seconds).
$this->hook('after', 'create', FollowUpHook::class, strategy: 'delay', options: ['delay' => 3600]); // or $hooks->addDelayedHook(OrderService::class, 'create', 'after', FollowUpHook::class, delay: 3600);
Batched
Collects executions and processes them together when the batch is full.
$hooks->addBatchedHook(OrderService::class, 'index', 'after', AnalyticsHook::class, options: [ 'batch_size' => 50, 'batch_delay' => 60, // seconds ]);
Conditional
Wraps any strategy with runtime conditions.
use Ahmed3bead\LaravelHooks\Strategies\ConditionalHookStrategy; use Ahmed3bead\LaravelHooks\Strategies\SyncHookStrategy; $strategy = (new ConditionalHookStrategy(new SyncHookStrategy())) ->onlyInEnvironment('production') ->onlyWhenConfigEnabled('features.audit'); $hooks->registerStrategy('prod_audit', $strategy); $hooks->addHook(OrderService::class, 'create', 'after', AuditHook::class, 'prod_audit');
Wrapping a Method
Use executeWithHooks() inside any method to trigger the full before/after/error cycle automatically:
public function process(array $data): mixed { return $this->executeWithHooks('process', function () use ($data) { return $this->doWork($data); }, $data); // optional: pass $data into HookContext::$data }
Writing Hook Classes
Extend BaseHookJob
use Ahmed3bead\LaravelHooks\BaseHookJob; use Ahmed3bead\LaravelHooks\HookContext; class MyHook extends BaseHookJob { protected int $priority = 10; // lower = runs first (default: 100) protected bool $async = false; // true = queued protected string $queueName = 'hooks'; // queue name when async public function handle(HookContext $context): void { // your logic } }
HookContext — what's available
public function handle(HookContext $context): void { // Core $context->method; // 'create', 'update', ... $context->phase; // 'before', 'after', 'error' $context->data; // value passed as $data to executeWithHooks() $context->result; // raw return value (after/error phase) $context->target; // the object the method was called on $context->user; // Auth::user() at the time of the call // Helpers $context->isBefore(); // true when phase === 'before' $context->isAfter(); // true when phase === 'after' $context->getParameter('key'); // named parameter by key $context->getMetadata('key'); // metadata value by key // Model extraction (works with plain models and wrapped responses) $context->getModelFromResult(); // first Eloquent model found in result $context->getDataFromResult(); // unwrapped data from result $context->getModelAttributes(); // model->toArray() $context->getModelChanges(); // model->getChanges() $context->getOriginalAttributes(); // model->getOriginal() $context->wasModelRecentlyCreated(); // model->wasRecentlyCreated // Response helpers (when result implements WrappedResponseInterface) $context->getStatusCode(); // HTTP status code $context->getMessage(); // response message $context->isSuccessful(); // true for 2xx status codes $context->hasWrappedResponse(); // true if result is a WrappedResponseInterface // User $context->getUserId(); // $user->id ?? $user->getKey() }
Conditional execution
class MyHook extends BaseHookJob { public function shouldExecute(HookContext $context): bool { return $context->user !== null; // only run for authenticated users } }
Inline Closures (No Class Required)
For quick, one-off hooks you don't need a full class:
// Synchronous inline hook $this->syncHookWithLogic('after', 'create', function (HookContext $ctx) { Log::info('Created', [ 'method' => $ctx->method, 'user_id' => $ctx->getUserId(), ]); }); // Inline hook with any strategy $this->hookWithLogic('after', 'create', function (HookContext $ctx) { dispatch(new SendWelcomeEmail($ctx->getUserId())); }, strategy: 'queue');
Priority
Lower numbers run first. Default priority is 100.
// Runs first $hooks->addHook(OrderService::class, 'create', 'after', ValidationHook::class, 'sync', ['priority' => 10]); // Runs last $hooks->addHook(OrderService::class, 'create', 'after', NotificationHook::class, 'sync', ['priority' => 200]);
Custom Response Objects
If your methods return a custom response wrapper, implement WrappedResponseInterface so hooks can extract the model and status automatically:
use Ahmed3bead\LaravelHooks\Contracts\WrappedResponseInterface; class ApiResponse implements WrappedResponseInterface { public function getData(): mixed { return $this->data; } public function getStatusCode(): int { return $this->statusCode; } public function getMessage(): string { return $this->message; } }
Artisan Commands
Generate a hook class
php artisan make:hook OrderCreated php artisan make:hook OrderCreated --type=audit --phase=after --queue php artisan make:hook OrderCreated --type=notification --sync
Available --type values: audit, notification, cache, logging, validation, security, analytics, general
Manage hooks at runtime
php artisan hooks:manage list # all registered hooks php artisan hooks:manage stats # counts, strategies, debug mode php artisan hooks:manage debug --target="App\Services\OrderService" # hooks for one class php artisan hooks:manage test # verify the system loads php artisan hooks:manage enable php artisan hooks:manage disable --force php artisan hooks:manage clear --force php artisan hooks:manage flush # flush pending batched hooks php artisan hooks:manage export --export=hooks.json
Configuration
config/laravel-hooks.php:
| Key | Default | Description |
|---|---|---|
enabled |
true |
Enable/disable all hooks globally |
debug |
false |
Log every hook registration and execution |
queue_connection |
null |
Queue connection for async hooks (null = default) |
default_queue |
'default' |
Queue name for queued/delayed hooks |
batch_queue |
'batch' |
Queue name for batched hooks |
include_request_metadata |
false |
Include IP address, user agent, and request ID in hook metadata (PII — opt-in) |
generation_directory |
'App\\Hooks' |
Namespace for generated hook classes |
Environment variables:
LARAVEL_HOOKS_ENABLED=true LARAVEL_HOOKS_DEBUG=false LARAVEL_HOOKS_QUEUE_CONNECTION=redis LARAVEL_HOOKS_DEFAULT_QUEUE=default LARAVEL_HOOKS_BATCH_QUEUE=batch LARAVEL_HOOKS_INCLUDE_REQUEST_METADATA=false
Testing
vendor/bin/pest vendor/bin/pest --coverage
Use Queue::fake() to assert queued hooks were dispatched without processing them:
use Illuminate\Support\Facades\Queue; use Ahmed3bead\LaravelHooks\Jobs\QueuedHookJob; Queue::fake(); $service->create(['name' => 'Test']); Queue::assertPushed(QueuedHookJob::class);
Upgrading from an Earlier Version
Renamed identifiers
| Old | New |
|---|---|
HookContext::$service |
HookContext::$target |
addServiceSyncHook() |
addSyncHookRegistration() |
addServiceQueuedHook() |
addQueuedHookRegistration() |
addServiceDelayedHook() |
addDelayedHookRegistration() |
addServiceBatchedHook() |
addBatchedHookRegistration() |
addServiceHook() |
addHookRegistration() |
removeServiceHooks() |
removeHooks() |
removeServiceHook() |
removeHook() |
enableServiceHooks() |
enableHooks() |
debugService() |
debugTarget() |
--service CLI flag |
--target CLI flag |
total_service_hooks stat key |
total_target_hooks |
service_hooks array key |
target_hooks |
All old names still work but emit E_USER_DEPRECATED. Update at your own pace.
Security
Safe logging
HookContext provides two serialization methods:
toArray()— includes rawdataandparameters. Use only for debugging in development.toLogArray()— excludes sensitive fields. Always use this for log output to prevent leaking passwords, tokens, or PII into log files.
All internal log calls use toLogArray(). Stack traces are only included in logs when debug mode is enabled.
Request metadata (PII)
By default, hook metadata does not include IP address, user agent, or request ID. Set include_request_metadata to true only if your application's privacy policy permits it:
LARAVEL_HOOKS_INCLUDE_REQUEST_METADATA=true
Closure hooks
Closure-based hooks (hookWithLogic(), syncHookWithLogic()) are restricted to the sync strategy. Attempting to use queue, delay, or batch will throw an InvalidArgumentException. This prevents PHP serialization failures at runtime.
Export path
The hooks:manage export command restricts output to storage/app/ and requires a .json extension to prevent path traversal.
Debug output
getStats() and debugTarget() expose internal class names and hook configuration. Never expose their output in public API responses or user-facing endpoints.
License
MIT
