skylence / laravel-interceptor
A compiled proxy-based plugin system for Laravel, inspired by Magento 2 but optimized for performance
Fund package maintenance!
skylence
Installs: 0
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/skylence/laravel-interceptor
Requires
- php: ^8.2
- illuminate/console: ^11.0|^12.0
- illuminate/contracts: ^11.0|^12.0
- illuminate/filesystem: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
- laravel/prompts: ^0.1|^0.2|^0.3
- symfony/finder: ^7.0
Requires (Dev)
- larastan/larastan: ^3.9
- nunomaduro/essentials: ^1.0
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
- phpstan/phpstan: ^2.1
- rector/rector: ^2.3
README
A compiled proxy-based plugin system for Laravel, inspired by Magento 2's interceptor pattern.
What is an Interceptor?
An interceptor allows you to modify, extend, or completely override the behavior of any class method without modifying the original source code. This is achieved by generating proxy classes that wrap the original classes and intercept method calls.
Use Cases
- Validation - Validate input before a method executes
- Logging & Auditing - Log method calls, parameters, and results
- Caching - Cache method results transparently
- Authorization - Check permissions before executing methods
- Event Dispatching - Trigger events before/after method execution
- Error Handling - Wrap methods with try/catch logic
- Performance Monitoring - Measure execution time
- Data Transformation - Modify input arguments or return values
- Feature Flags - Conditionally modify behavior
- Third-party Package Extension - Extend vendor code without forking
How It Works
flowchart TB
subgraph build["BUILD TIME (artisan)"]
scan["Scan #[Plugin]<br>attributes"] --> chain["Build chain<br>map + cache"] --> generate["Generate proxy<br>classes"]
end
subgraph runtime["RUNTIME"]
resolve["Container resolves"] --> proxy["Returns Proxy"] --> execute["Executes plugins"]
end
build --> runtime
Loading
- At build time, plugins are scanned and proxy classes are generated
- At runtime, the container returns the proxy instead of the original class
- When a method is called, the proxy executes: before → around → original → after
Features
- Before plugins - Modify method arguments before execution
- Around plugins - Wrap method execution with custom logic
- After plugins - Modify return values after execution
- Compiled proxies - Zero runtime overhead after compilation
- HMAC-signed cache - Tamper-proof cache files
- Circular dependency detection - Prevents infinite loops
Requirements
- PHP 8.2+
- Laravel 11+
Installation
composer require skylence/laravel-interceptor
Publish the configuration file:
php artisan vendor:publish --tag=interceptor-config
Quick Start
1. Create a Plugin
Create a plugin class with the #[Plugin] attribute:
<?php namespace App\Plugins; use App\Services\OrderService; use Skylence\LaravelInterceptor\Attributes\Plugin; #[Plugin(target: OrderService::class, sortOrder: 10)] class OrderValidationPlugin { public function beforePlace(OrderService $subject, Order $order): ?array { // Validate order before placing if ($order->total < 0) { throw new InvalidOrderException('Order total cannot be negative'); } // Return null to keep original arguments // Return array to replace arguments return null; } public function afterPlace(OrderService $subject, mixed $result, Order $order): mixed { // Log after order is placed logger()->info('Order placed', ['order_id' => $result->id]); return $result; } }
2. Compile Plugins
php artisan interceptor:compile
3. Use the Service Normally
// The proxy is automatically injected $orderService = app(OrderService::class); $orderService->place($order); // Plugins execute automatically
Plugin Types
Before Plugins
Executed before the original method. Can modify arguments.
#[Plugin(target: UserService::class)] class UserValidationPlugin { /** * Method name: before + PascalCase(targetMethod) * * @param UserService $subject The original service instance * @param string $email First argument of create() * @param string $name Second argument of create() * @return array|null Return array to replace args, null to keep original */ public function beforeCreate(UserService $subject, string $email, string $name): ?array { // Normalize email $email = strtolower(trim($email)); // Return modified arguments return [$email, $name]; } }
Around Plugins
Wrap the method execution. Must call $proceed to continue.
#[Plugin(target: PaymentService::class)] class PaymentLoggingPlugin { /** * @param PaymentService $subject The original service * @param \Closure $proceed Call to execute next plugin or original method * @param Payment $payment Original method arguments follow */ public function aroundProcess( PaymentService $subject, \Closure $proceed, Payment $payment ): mixed { $startTime = microtime(true); try { // Call the original method (or next around plugin) $result = $proceed($payment); logger()->info('Payment processed', [ 'duration' => microtime(true) - $startTime, ]); return $result; } catch (\Throwable $e) { logger()->error('Payment failed', ['error' => $e->getMessage()]); throw $e; } } }
After Plugins
Executed after the original method. Can modify the result.
#[Plugin(target: ProductRepository::class)] class ProductCachePlugin { public function __construct( private CacheManager $cache ) {} /** * @param ProductRepository $subject * @param mixed $result The return value from the method * @param int $id Original method arguments follow */ public function afterFind(ProductRepository $subject, mixed $result, int $id): mixed { // Cache the result if ($result !== null) { $this->cache->put("product.{$id}", $result, 3600); } return $result; } }
Plugin Attribute Options
#[Plugin(
target: TargetClass::class, // Required: Class to intercept
sortOrder: 10, // Optional: Execution order (lower = first)
disabled: false, // Optional: Disable this plugin
methods: ['save', 'delete'] // Optional: Only intercept these methods
)]
Commands
Compile Plugins
Scan for plugins and generate proxy classes:
php artisan interceptor:compile # Force recompilation php artisan interceptor:compile --force # Skip caching (for debugging) php artisan interceptor:compile --no-cache
List Plugins
View all registered plugins:
php artisan interceptor:list # Filter by target class php artisan interceptor:list --target=UserService # Scan fresh instead of using cache php artisan interceptor:list --scan
Clear Compiled Data
Remove all generated proxies and cache:
php artisan interceptor:clear # Skip confirmation php artisan interceptor:clear --force # Only clear proxies php artisan interceptor:clear --proxies # Only clear cache php artisan interceptor:clear --cache
Configuration
// config/interceptor.php return [ // Where to store generated proxy classes 'proxy_path' => storage_path('framework/interceptor/proxies'), // Directories to scan for plugins 'scan_paths' => [ app_path('Plugins'), ], // Classes that should never be intercepted 'exclude' => [ // App\Services\CriticalService::class, ], // Auto-compile on boot (development only!) 'auto_compile' => env('INTERCEPTOR_AUTO_COMPILE', false), // Cache driver (null = default) 'cache_driver' => env('INTERCEPTOR_CACHE_DRIVER'), // Include debug info in proxies 'debug' => env('INTERCEPTOR_DEBUG', false), // HMAC signature verification for cache files 'verify_cache_signatures' => env('INTERCEPTOR_VERIFY_SIGNATURES', true), // Cache TTL in seconds (null = 10 years) 'cache_ttl' => env('INTERCEPTOR_CACHE_TTL'), // Pretty print cache files (slower, for debugging) 'pretty_print_cache' => env('INTERCEPTOR_PRETTY_PRINT', false), // Max plugin instances to cache in memory 'max_plugin_instances' => env('INTERCEPTOR_MAX_INSTANCES', 100), ];
Using in Laravel Packages
When building a Laravel package that uses the interceptor system:
1. Package Structure
your-package/
├── src/
│ ├── Plugins/
│ │ └── YourPlugin.php
│ ├── Services/
│ │ └── YourService.php
│ └── YourPackageServiceProvider.php
├── config/
│ └── your-package.php
└── composer.json
2. Register Plugin Scan Path
In your service provider, add your plugin directory to the scan paths:
<?php namespace YourVendor\YourPackage; use Illuminate\Support\ServiceProvider; use Skylence\LaravelInterceptor\InterceptorManager; class YourPackageServiceProvider extends ServiceProvider { public function boot(): void { // Add your plugin directory to scan paths if ($this->app->bound(InterceptorManager::class)) { $this->app->make(InterceptorManager::class) ->addScanPath(__DIR__.'/Plugins'); } } }
3. Package Plugin Example
<?php namespace YourVendor\YourPackage\Plugins; use App\Models\User; use Skylence\LaravelInterceptor\Attributes\Plugin; #[Plugin(target: User::class, sortOrder: 100)] class UserAuditPlugin { public function afterSave(User $subject, mixed $result): mixed { // Audit log for user saves activity() ->performedOn($subject) ->log('User saved'); return $result; } }
4. Deployment Instructions
Add to your package documentation:
## Post-Installation After installing this package, recompile the interceptor plugins: php artisan interceptor:compile --force Add this command to your deployment script after `composer install`.
5. Composer Scripts
Add to the host application's composer.json:
{
"scripts": {
"post-autoload-dump": [
"@php artisan interceptor:compile --force --quiet"
]
}
}
Best Practices
DO
- Compile in deployment - Always run
interceptor:compilein your deployment script - Use sortOrder - Set explicit sort order when plugin execution order matters
- Keep plugins focused - One responsibility per plugin
- Use dependency injection - Plugins support constructor injection
- Test plugins - Write tests for your plugin logic
DON'T
- Don't enable auto_compile in production - It causes performance issues
- Don't intercept framework classes - Add them to the exclude list
- Don't create circular dependencies - Plugin A targeting Plugin B and vice versa
- Don't modify $subject state in before plugins - Use return value to modify arguments
Execution Order
When multiple plugins target the same method:
flowchart TB
subgraph before["Before Plugins"]
b1["1. BeforePlugin (sortOrder: 10)"]
b2["2. BeforePlugin (sortOrder: 20)"]
b1 --> b2
end
subgraph around["Around Plugins"]
a1["3. AroundPlugin (sortOrder: 10)"]
a2["4. AroundPlugin (sortOrder: 20)"]
orig["5. Original Method"]
a2_after["6. AroundPlugin (sortOrder: 20) after proceed"]
a1_after["7. AroundPlugin (sortOrder: 10) after proceed"]
a1 --> a2 --> orig --> a2_after --> a1_after
end
subgraph after["After Plugins"]
af1["8. AfterPlugin (sortOrder: 10)"]
af2["9. AfterPlugin (sortOrder: 20)"]
af1 --> af2
end
before --> around --> after
Loading
Limitations
The following cannot be intercepted:
finalclassesprivateorprotectedmethodsstaticmethods- Magic methods (
__construct,__get, etc.) - Interfaces, traits, enums, abstract classes
- Internal PHP classes
Troubleshooting
Plugins not executing
- Run
php artisan interceptor:compile --force - Check
php artisan interceptor:listto verify plugins are registered - Ensure the target class is resolved through the container
Class not found errors
- Verify the proxy path is writable
- Check autoloader is registered (should be automatic)
- Run
composer dump-autoload
Cache signature verification failed
- Run
php artisan interceptor:clear --force - Run
php artisan interceptor:compile - Ensure
APP_KEYis set consistently across environments
Security
- Cache files are HMAC-signed using your
APP_KEY - Path traversal attempts are blocked
- Only public methods can be intercepted
- Proxy classes have restricted file permissions (0640)
License
MIT License. See LICENSE for details.