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

v1.0.0 2026-01-23 10:42 UTC

This package is auto-updated.

Last update: 2026-01-24 23:54:42 UTC


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
  1. At build time, plugins are scanned and proxy classes are generated
  2. At runtime, the container returns the proxy instead of the original class
  3. 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:compile in 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:

  • final classes
  • private or protected methods
  • static methods
  • Magic methods (__construct, __get, etc.)
  • Interfaces, traits, enums, abstract classes
  • Internal PHP classes

Troubleshooting

Plugins not executing

  1. Run php artisan interceptor:compile --force
  2. Check php artisan interceptor:list to verify plugins are registered
  3. Ensure the target class is resolved through the container

Class not found errors

  1. Verify the proxy path is writable
  2. Check autoloader is registered (should be automatic)
  3. Run composer dump-autoload

Cache signature verification failed

  1. Run php artisan interceptor:clear --force
  2. Run php artisan interceptor:compile
  3. Ensure APP_KEY is 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.