February 9th, 2024 Laravel PHP

Dear Laravel package authors...

If I may, I have a couple of remarks that I'd like to get off my chest. While it's undeniable that Laravel is one of the most thriving OSS communities, with Spatie being the undisputed champion, there are a couple of things — I think — we can collectively do better on. I have personally been guilty of all of the points raised below in the past, and am going to be the first one to take them into consideration.

So without further ado, let's dive right in!

Auto-discovery over-reliance

As Laravel developers, auto-discovery (AD) has undoubtedly made our lives much, much easier. Remember the days when we always had to composer require and always forgot to add the package's ServiceProvider to config/app.php? Even though this is an extremely useful feature that alleviates our daily lives, it doesn't come for free. Every decision in software development should be made consciously by weighing out the various trade-offs.

That's why there are still folks out there that do not opt into package auto-discovery due to its contribution to longer app boot times. The problem is, auto-discovery eagerly loads all package ServiceProviders even though you might only need a fraction of them for the current, actively served request. There are ways to lazily load your needed ServiceProviders, and no, Octane is not the obvious answer to this problem. But that's a topic for another blog post. If I may put the lazy loading benefits into perspective: we have been able to shave off up to 80 ms of application boot times just by playing the dependency registration game the clever way! Why register and boot Livewire, Blade, Horizon, Telescope, Nova etc. (you get the gist) if you are just going to respond with a plain JSON payload?

My point is that there are Laravel developers who consciously opt out of this feature and are unfortunately left in the dark. "But how?", you might rightfully ask. And I'm glad you asked! Please let me explain.

Sleeper breaking changes

"Sleeper what?" — Now I must confess that I've just coined the notion "Sleeper Breaking Changes" (SBC), but I think it describes the problem very well. These are breaking changes that are introduced, despite you paying close attention to not introduce them, in minor releases just because you don't know they're happening.

Explained by example: Filament

Personally speaking, I've had to deal with this problem with a minor Filament upgrade. At some point, someone decided to modularize the notification behavior of the administration panel. Everything was made to be backwards compatible, so there's no harm in tagging it in a minor release right? Unfortunately, there was an SBC waiting to ambush. The library fully relies on auto-discovery and bets on the fact that everyone is also following the same philosophy. Well... that's not always the case.

What happened? Long story short, a seemingly harmless composer update that was run locally and got pushed to production broke the entire application because a key dependency was missing. This is not illogical either, because this is how the non-auto-discovery (NAD) folks currently have to circumvent the problem (add the library and its sub-dependencies one by one):

'providers' => [
    \BladeUI\Icons\BladeIconsServiceProvider::class,
    \BladeUI\Heroicons\BladeHeroiconsServiceProvider::class,
    \Kirschbaum\PowerJoins\PowerJoinsServiceProvider::class,
    \Filament\FilamentServiceProvider::class,
    \Filament\Actions\ActionsServiceProvider::class,
    \Filament\Forms\FormsServiceProvider::class,
    \Filament\Infolists\InfolistsServiceProvider::class,
    \Filament\Notifications\NotificationsServiceProvider::class,
    \Filament\Support\SupportServiceProvider::class,
    \Filament\Tables\TablesServiceProvider::class,
    \Filament\Widgets\WidgetsServiceProvider::class,
    \Livewire\LivewireServiceProvider::class,
];

If a new key dependency is added by the library, it must be added by the package consumers manually. This is often forgotten about by the package consumers because this should not be the case in the first place. Ideally, we should only need to register a singular dependency and call it a day. Like so:

'providers' => [
    \Filament\FilamentServiceProvider::class,
];

Everything else is an implementation detail of the Filament package. If something new gets used behind the scenes, we should not be made aware of it in the shape of ruthless 500 explosions on production. "But is this problem even solvable?" You bet! It's as easy as pie.

Enter the AggregateServiceProvider...

Glorious AggregateServiceProvider

The cure for this ache lies in the AggregateServiceProvider. I think that this class is the single most important file ever in the entire Laravel codebase! It has a simple purpose, can you guess what it is? I'll give you 5 seconds...

... aggregating other service providers (read: dependencies). That was hard. Basically, it allows us to gather all required dependencies under a single, overarching roof. This has multiple benefits:

  • Package dependencies are made clear immediately.
  • Every type of application is taken care of (AD vs. NAD folks).
  • Allows us to build a mental model of the package interdependencies.
  • Laravel's IoC container is smart enough to skip already registered ServiceProviders, so multiple registrations won't happen.

If you went to Laracon EU '24 and saw Mateus's talk on modular monoliths, then you need to know about this class because it's the sharpest tool in the shed with regards to modularizing Laravel applications!

Proposed solution

If you are a package creator, then please do use the glorious AggregateServiceProvider. I'll happily give an example from my Livewire Toaster package:

final class ToasterServiceProvider extends AggregateServiceProvider
{
    protected $providers = [LivewireServiceProvider::class];
    
    // omitted for brevity
}

As you can see, the package has a dependency on Livewire. That wasn't hard to guess, but we should make this dependency explicit as depicted above. Whether this package then gets installed in an AD or NAD application now irrelevant. The NAD folks will have to manually register ToasterServiceProvider once and forget about the rest from that point onward. If I suddenly decide to make use of another dependency, it won't blow up production — hooray!

Under-utilization of container events

Have you ever heard about "Container Events"? No? Well, I don't blame you because it's such a tiny subject in the official Laravel documentation, even though it can help speed up our application boot times by a ton. Simply put, they're a way to hook into the container's object resolution lifecycle of various services that might be used throughout the entire lifespan of an incoming web request. Why is it important to know? Well, one word: performance.

You see, most open-source libraries out there actually enhance Laravel's existing components by extending their functionalities in lieu of being a completely independent component themselves. This is how it also should be, because why reinvent the wheel if it already exists? Though we should still be wary of our surroundings and not request something we aren't going to need.

Explained by example: Inertia

Let me give an example from a PR I submitted to Inertia a couple of years ago, which will drive the point home.

protected function registerBladeDirectives(): void
{
    Blade::directive('inertia', [Directive::class, 'compile']);
    Blade::directive('inertiaHead', [Directive::class, 'compileHead']);
}

This code snippet might look familiar. After all, we are making use of exquisite façades to register a couple of Blade directives. The only thing we need to be careful with is registering it in boot instead of register because otherwise the service might not be available yet at that point in time. So far so good, so what's the problem? Well, for this one we first need to understand how the Inertia protocol operates behind the scenes.

If a client makes a first time request, meaning they typed out a URL in their browser window and hit enter, then the web page is going to be served as-is. When a subsequent request comes in, meaning the user interacted with the JavaScript UI, Inertia bypasses all of the HTML rendering logic and spits out a JSON payload as response. This is also where the "CPU cycle waste" came into play: even though Laravel's Blade component is needed for the very first client request, it was still being resolved for every subsequent request; all of this additional overhead while it is perfectly known, ahead of time, that it is not going to be needed! This means that Blade is being resolved for no reason during 99% of all total requests!

Proposed solution

If you are a package creator, then please do make use of container events:

$this->callAfterResolving('blade.compiler', function ($blade) {
    $blade->directive('inertia', [Directive::class, 'compile']);
    $blade->directive('inertiaHead', [Directive::class, 'compileHead']);
});

Inertia has already been taken care of, so now it's your turn!

Also, to clarify, container events can be used with any service that needs to be resolved through the container. Blade is only one example among many.

Ghosting the decorator pattern

This is going to be a brief section, because I have already dedicated an entire blog post to the decorator pattern. If you have any biases against this pattern, then I kindly request you to go ahead and read that blog post first.

We seem to generally avoid the decorator design pattern. But why? It's such a powerful tool in our toolbox. Perhaps because it's mutually exclusive with the use of Laravel façades? That couldn't be further from the truth. If you look all the way down in the example below, you can spot a tiny, little interface called Collector:

final class Toaster extends Facade
{
    // omitted for brevity

    public static function error(string $message, array $replace = []): PendingToast
    {
        return self::toast()->message($message, $replace)->error();
    }

    #[Override]
    protected static function getFacadeAccessor(): string
    {
        return ToasterServiceProvider::NAME;
    }

    #[Override]
    protected static function getMockableClass(): string
    {
        return Collector::class;
    }
}

Do you know how it is being used in the corresponding ServiceProvider? No? Well, that means you haven't read my previous blog post, so please do so!

if ($config->wantsAccessibility) {
    $this->app->extend(Collector::class, static fn (Collector $next) => new AccessibleCollector($next));
}

if ($config->wantsTranslation) {
    $this->app->extend(Collector::class, fn (Collector $next) => new TranslatingCollector($next, $this->app['translator']));
}

Through the power and might of decorators, we are able to add behavior at runtime! What does this mean for you as the package consumer?

  • You can keep using the façade like Toaster::error('Uh oh!')
  • You can inject the service if you prefer that (constructor / method injection)
  • You can mix and match the points above
  • You can do something else

The possibilities are endless and the package makes it possible to use any approach you'd like! But what if you'd like to extend some behavior personally?

Explained by example: extending behavior

Something decorators provide us for free is the fact that extending existing behavior becomes child's play. Imagine that you decided to use my package Livewire Toaster and would like to keep track of how many toasts are dispatched daily to display on an admin dashboard. The way you might tackle this problem is first by creating a new class:

final readonly class DailyCountingCollector implements Collector
{
    public function __construct(private Collector $next) {}

    public function collect(Toast $toast): void
    {
        // increment the counter on durable storage

        $this->next->collect($toast);
    }

    public function release(): array
    {
        return $this->next->release();
    }
}

And then extending the behavior in your AppServiceProvider:

public function register(): void
{
    $this->app->extend(Collector::class, 
        static fn (Collector $next) => new DailyCountingCollector($next)
    );
}

That's it! There is nothing else needing to be modified in order to get this thing to work! It's that simple. I hope this is plenty of evidence that thinking out-of-the-box might be beneficial to us all.

Wrap-up

This blog post should not be read as a complaint submission, but rather as an improvement recipe so we can be inclusive and raise the bar even more. None of the references made above should be seen as an attack against the package authors! They're solely used due to my personal involvement and serve as real-world examples. Don't forget to show the authors some love by way of starring their repositories!

Join the discussion on X (formerly Twitter)! I'd love to know what you thought about this blog post.

Thanks for reading!