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:
| Need | Native seam | Keep it small by |
|---|---|---|
| Reusable fluent transformation | macro or mixin | accepting values and returning values |
| Reusable business validation | rule object | returning one clear validation failure |
| Repeated view syntax | component first, directive second | keeping work out of templates |
| Logging, cache, metrics around your service | container extend() decorator | depending on your contract, not a facade |
| Configured choice between transports | Manager and drivers | treating driver names as configuration |
| Deliberate operational work | Artisan command | making 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
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
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
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
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
declare(strict_types=1);
namespace App\Delivery;
final readonly class DeliveryMessage
{
public function __construct(
public string $recipient,
public string $body,
public string $reference,
) {
}
}
<?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
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
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
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
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
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
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
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
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
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
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.