Extending Laravel the Blessed Way: Macros, Drivers and Directives

12 min read

Laravel is intentionally extensible, but that does not make every extension a good one. A helper added to a facade can be harmless. The same convenience implemented by replacing a framework class in vendor/, overriding an internal method, or scattering app() calls through controllers becomes a maintenance liability at the next upgrade.

The useful question is not “how can I make Laravel do this?” It is “which public seam already describes this kind of change?” A macro adds a small local convenience to a fluent API. A custom validation rule expresses domain knowledge. A Blade component or directive changes presentation syntax. A decorator adds behaviour around an application contract. A manager owns a named family of runtime-selectable drivers. Commands are explicit operator interfaces.

This article builds a small delivery subsystem and uses it to choose those seams. The aim is not to catalogue every hook Laravel offers. It is to make the next developer able to locate, test, configure, and eventually delete an extension without reverse-engineering framework internals.

Start with the change, not with a hook

There is a recurring anti-pattern in Laravel projects: a team sees a familiar facade and adds a macro because it is the shortest path to a result. The macro then reads configuration, performs HTTP I/O, starts a database transaction, and knows about the authenticated user. It is no longer a convenience method; it is hidden application behaviour attached to a global name.

Use this decision rule instead:

NeedNative seamKeep it small by
Reusable fluent transformationmacro or mixinaccepting values and returning values
Reusable business validationrule objectreturning one clear validation failure
Repeated view syntaxcomponent first, directive secondkeeping work out of templates
Logging, cache, metrics around your servicecontainer extend() decoratordepending on your contract, not a facade
Configured choice between transportsManager and driverstreating driver names as configuration
Deliberate operational workArtisan commandmaking side effects explicit and observable

The distinction matters because each seam has a different lifecycle. Macros are global process state. A resolved manager caches driver instances. A decorator changes every resolution of a contract. A command should tolerate being invoked by a scheduler, a human, or a deployment script. Choosing the wrong seam creates bugs that only appear under queues, Octane workers, cached configuration, or a future package upgrade.

A macro improves vocabulary, not the service layer

Suppose incoming partner identifiers need a consistent canonical form. This is a pure string transformation with no collaborators. A Str macro is a good fit because the call site remains readable and the implementation has no hidden I/O.

php
<?php

declare(strict_types=1);

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;

final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
    }

    public function boot(): void
    {
        Str::macro('partnerReference', static function (string $value): string {
            $normalized = Str::upper(Str::replaceMatches('/[^A-Za-z0-9]+/', '-', $value));

            return Str::trim($normalized, '-');
        });
    }
}

The macro is registered once while the application boots, then remains available as Str::partnerReference('acme / 42'). It returns a value, has no request state, and can be exercised in a small test.

php
<?php

declare(strict_types=1);

use Illuminate\Support\Str;

it('normalizes a partner reference', function (): void {
    expect(Str::partnerReference(' acme / north 42 '))->toBe('ACME-NORTH-42');
});

That test also makes macros' global nature visible. Register only stable behaviour here: a macro name is effectively an internal public API. Do not put tenant-specific values, an authenticated user, or a request-bound service in its closure. Long-lived workers can keep that state after the request that created it has finished.

A mixin helps when several transformations form one coherent vocabulary. It does not fix a design merely by moving macros into another class. If code needs a repository, an HTTP client, retries, or a feature flag, use an ordinary injected service with an explicit name instead.

Put domain validation in a rule object

Validation is another place where closures look tempting. A closure is fine once. A rule object wins as soon as its explanation, tests, or dependencies matter. Here an order may be sent to a delivery provider only when its value is above the configured minimum.

php
<?php

declare(strict_types=1);

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

final readonly class MeetsDeliveryMinimum implements ValidationRule
{
    public function __construct(
        private int $minimumCents,
    ) {
    }

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (filter_var($value, FILTER_VALIDATE_INT) === false || (int) $value < $this->minimumCents) {
            $fail("The {$attribute} must be at least {$this->minimumCents} cents.");
        }
    }
}

The request stays declarative and the policy has one discoverable home. The example assumes that Delivery is imported from App\Models.

php
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\Delivery;
use App\Rules\MeetsDeliveryMinimum;
use Illuminate\Foundation\Http\FormRequest;

final class CreateDeliveryRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()?->can('create', Delivery::class) ?? false;
    }

    /** @return array<string, list<MeetsDeliveryMinimum|string>> */
    public function rules(): array
    {
        return [
            'amount_cents' => ['required', 'integer', new MeetsDeliveryMinimum(500)],
        ];
    }
}

Do not turn a small rule into a query engine. A validation rule may ask one focused collaborator one question, but authorization belongs in a policy and multi-step business decisions belong in an action or domain service.

Decorate a contract you own

Decorating is the right answer when the operation itself does not change, but every caller should gain a cross-cutting concern such as logging, metrics, or caching. The important boundary is an application contract, not an Illuminate concrete class. That gives you a stable point to test and replace.

php
<?php

declare(strict_types=1);

namespace App\Delivery;

final readonly class DeliveryMessage
{
    public function __construct(
        public string $recipient,
        public string $body,
        public string $reference,
    ) {
    }
}
php
<?php

declare(strict_types=1);

namespace App\Delivery;

interface DeliveryChannel
{
    public function send(DeliveryMessage $message): void;
}

The local driver is deliberately complete too. It makes development and operational verification possible without a real provider account.

php
<?php

declare(strict_types=1);

namespace App\Delivery;

use Psr\Log\LoggerInterface;

final readonly class LogDeliveryChannel implements DeliveryChannel
{
    public function __construct(
        private LoggerInterface $logger,
    ) {
    }

    public function send(DeliveryMessage $message): void
    {
        $this->logger->info('Delivery message accepted.', [
            'reference' => $message->reference,
            'recipient_type' => str($message->recipient)->before(':')->toString(),
        ]);
    }
}
php
<?php

declare(strict_types=1);

namespace App\Delivery;

use Illuminate\Http\Client\Factory as HttpFactory;

final readonly class WebhookDeliveryChannel implements DeliveryChannel
{
    public function __construct(
        private HttpFactory $http,
        private string $endpoint,
        private string $token,
    ) {
    }

    public function send(DeliveryMessage $message): void
    {
        $this->http
            ->acceptJson()
            ->withToken($this->token)
            ->connectTimeout(2)
            ->timeout(5)
            ->retry([100, 250], throw: false)
            ->post($this->endpoint, [
                'recipient' => $message->recipient,
                'body' => $message->body,
                'reference' => $message->reference,
            ])
            ->throw();
    }
}

Now add logging without changing callers or the transport:

php
<?php

declare(strict_types=1);

namespace App\Delivery;

use Psr\Log\LoggerInterface;

final readonly class LoggedDeliveryChannel implements DeliveryChannel
{
    public function __construct(
        private DeliveryChannel $inner,
        private LoggerInterface $logger,
    ) {
    }

    public function send(DeliveryMessage $message): void
    {
        $this->logger->info('Sending delivery message.', [
            'reference' => $message->reference,
            'channel' => $this->inner::class,
        ]);

        $this->inner->send($message);
    }
}

Bind the concrete implementation, then extend its contract in a provider. The closure runs when the container resolves the binding, so it must return the same contract.

php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Delivery\DeliveryChannel;
use App\Delivery\LoggedDeliveryChannel;
use App\Delivery\WebhookDeliveryChannel;
use Illuminate\Contracts\Container\Container;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Psr\Log\LoggerInterface;

final class DeliveryServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(DeliveryChannel::class, function (Application $app): DeliveryChannel {
            return new WebhookDeliveryChannel(
                http: $app->make(HttpFactory::class),
                endpoint: (string) config('delivery.drivers.webhook.endpoint'),
                token: (string) config('delivery.drivers.webhook.token'),
            );
        });

        $this->app->extend(DeliveryChannel::class, function (DeliveryChannel $channel, Container $app): DeliveryChannel {
            return new LoggedDeliveryChannel(
                inner: $channel,
                logger: $app->make(LoggerInterface::class),
            );
        });
    }
}

The decorator deliberately logs a reference, not the body or recipient. A cross-cutting concern is where accidental personal-data leakage often starts. Use structured context, decide retention explicitly, and make a failing transport observable without recording credentials or whole payloads.

Use a manager when configuration chooses a family of drivers

A decorator has one inner implementation. A manager solves a different problem: the application needs a named driver selected from configuration, with each driver sharing one contract. Laravel's cache, filesystem, queue, and notification systems use this shape. Reuse one of those existing managers via its documented extend() method whenever you are extending that subsystem.

For an application-specific subsystem, a small Manager makes selection honest rather than encoding it in a growing match statement.

php
<?php

declare(strict_types=1);

namespace App\Delivery;

use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Support\Manager;
use Psr\Log\LoggerInterface;

final class DeliveryManager extends Manager
{
    public function getDefaultDriver(): string
    {
        return (string) config('delivery.default');
    }

    protected function createWebhookDriver(): DeliveryChannel
    {
        return new WebhookDeliveryChannel(
            http: $this->container->make(HttpFactory::class),
            endpoint: (string) config('delivery.drivers.webhook.endpoint'),
            token: (string) config('delivery.drivers.webhook.token'),
        );
    }

    protected function createLogDriver(): DeliveryChannel
    {
        return new LogDeliveryChannel(
            logger: $this->container->make(LoggerInterface::class),
        );
    }
}

LogDeliveryChannel is another complete DeliveryChannel implementation; its send() method writes only the reference and recipient type to the logger. The configuration tells an operator which driver to use:

php
<?php

declare(strict_types=1);

return [
    'default' => env('DELIVERY_DRIVER', 'log'),

    'drivers' => [
        'webhook' => [
            'endpoint' => env('DELIVERY_WEBHOOK_ENDPOINT'),
            'token' => env('DELIVERY_WEBHOOK_TOKEN'),
        ],
    ],
];

The env() calls stay in the configuration file. Every application class reads cached configuration through config(). That distinction matters after config:cache: environment values are not a runtime configuration API.

Register the manager as a singleton. A manager caches its resolved drivers; if credentials change at runtime, explicitly call forgetDrivers() or, more commonly, restart workers after a deployment.

php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Delivery\DeliveryManager;
use Illuminate\Support\ServiceProvider;

final class DeliveryServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(DeliveryManager::class);
    }
}

An application or package can register another driver without editing the manager:

php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Delivery\DeliveryChannel;
use App\Delivery\DeliveryManager;
use App\Delivery\LogDeliveryChannel;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Psr\Log\LoggerInterface;

final class AuditDeliveryServiceProvider extends ServiceProvider
{
    public function boot(DeliveryManager $delivery): void
    {
        $delivery->extend('audit', function (Application $app): DeliveryChannel {
            return new LogDeliveryChannel(
                logger: $app->make(LoggerInterface::class),
            );
        });
    }
}

Do not create a manager for two fixed implementations selected only by the consumer's class. Contextual binding or a small resolver is clearer there. A manager earns its lifecycle only when human-readable configuration and an open set of drivers are part of the problem.

Directives are the last presentation seam

Blade components are usually better than directives: they have explicit attributes, templates, and testable rendering. A directive is useful for a tiny piece of syntax that maps directly to existing Laravel behaviour. This one formats an amount in cents without hiding business calculation.

php
<?php

declare(strict_types=1);

namespace App\Providers;

use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;

final class BladeServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Blade::directive('money', static function (string $expression): string {
            return "<?php echo e(number_format(({$expression}) / 100, 2)); ?>";
        });
    }
}

The directive only produces escaped output. It does not look up exchange rates, query orders, or decide which currency a customer deserves. Put those decisions in a presenter or view model, then pass the final value to a component. Directives compile to PHP, so unclear directives are particularly painful to debug once they spread through templates.

Test the seam, not Laravel itself

Each extension has a small behaviour worth testing. For the manager, bind it in a test application and assert that the selected driver satisfies the application contract. For the decorator, fake the HTTP client and make the assertion at the boundary.

php
<?php

declare(strict_types=1);

use App\Delivery\DeliveryChannel;
use App\Delivery\DeliveryManager;
use App\Delivery\LogDeliveryChannel;

it('resolves the configured delivery driver', function (): void {
    config()->set('delivery.default', 'log');

    $manager = app(DeliveryManager::class);

    expect($manager->driver())
        ->toBeInstanceOf(LogDeliveryChannel::class)
        ->toBeInstanceOf(DeliveryChannel::class);
});

Tests must also isolate macro state. flushMacros() is appropriate in afterEach() when a suite registers temporary macros; otherwise one test can make another appear to pass. For driver tests, clear a manager's cached drivers after changing configuration. The implementation is correct only when the test controls the same lifecycle production will use.

When not to extend Laravel

Do not extend Laravel merely because a method is inconvenient. A private application service is often safer. Avoid a macro when it has dependencies or user context. Avoid a directive when a Blade component can describe the same UI. Avoid container extend() when only one caller needs the behaviour—inject the collaborator directly. Avoid a custom manager when the runtime choice is a business rule that needs a strategy or resolver.

Most importantly, do not subclass, monkey-patch, or copy framework internals to alter a public feature. Laravel's documented extension points are upgrade boundaries; protected and private internals are not. If no public seam fits, write an application service beside Laravel rather than forcing the framework to become your domain model.

Production pitfalls

Configuration cache is the first trap: env() outside configuration files can return surprising values after deployment. The second is process lifetime: macros and singleton managers persist in queue workers and Octane workers, so never capture per-request state and restart workers when deployment changes configuration or code.

The third is ordering. A package extending an existing Laravel manager must register the driver before any provider resolves it; Laravel's cache-driver documentation uses a booting() callback for precisely this reason. Finally, resist abstraction inflation. A named extension point should make the next change more local. If adding a driver requires editing controllers, templates, and a dozen conditionals, the missing boundary is in application code, not in Laravel.

The blessed path is conservative on purpose: use the public hook that matches the kind of change, keep that hook thin, and let ordinary injected classes own the actual application behaviour.

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.