Rajtík's blog

← Back to home

Programming 5 min read

Livewire 4 and dependency injection: where it works and where it does not

Where dependency injection fails in Livewire 4 and why - a look into the source and a practical fix.

The Livewire 4 documentation says one reassuring sentence in the lifecycle hooks section: "You can use dependency injection with all hook methods." That is true, but only for lifecycle hooks. There are several other places in a component where DI does not work and where Livewire quietly lets you run into an ArgumentCountError. This article is about where and why.

A quick reminder: what DI is in Laravel

Dependency injection in Laravel means that instead of creating dependencies manually (new SomeService()) you just type-hint a parameter and the service container hands you the instance. It works in three forms:

Controller constructor:

class InvoiceController extends Controller
{
    public function __construct(
        private InvoiceRepository $repository,
    ) {}
}

Method injection in controllers and route closures:

public function show(Request $request, InvoiceRepository $repository, int $id)
{
    return $repository->find($id);
}

Manual resolve through the container:

$repository = app(InvoiceRepository::class);
// or
$repository = resolve(InvoiceRepository::class);

Under the hood it is done by Illuminate\Container\Container::call(). It walks the method's parameters through reflection, looks at the type hints and resolves each one.

How it works in a Livewire component

In a Livewire component you do not write __construct. Livewire reconstructs the component on every subsequent request, so a constructor would be called again each time without your data. Instead there are lifecycle hooks (mount, boot, hydrate, …) and public methods as actions.

When Livewire is about to call a method on a component, it usually wraps it in the wrap() helper. That internally uses ImplicitlyBoundMethod::call() from the Laravel container, and this very path is what does the DI. An example from the Livewire source (SupportLifecycleHooks::callHook):

public function callHook($name, $params = [])
{
    if (method_exists($this->component, $name)) {
        wrap($this->component)->__call($name, $params);
    }
}

And Wrapped::__call():

function __call($method, $params)
{
    if (! method_exists($this->target, $method)) return value($this->fallback);

    return ImplicitlyBoundMethod::call(app(), [$this->target, $method], $params);
}

If a method call goes through here, DI works. If not, you are out of luck. In Livewire 4 there are at least two places where wrap() is not used and the method is called directly as $this->method() or through invade().

Where DI does not work in Livewire 4

1. Computed properties (#[Computed])

This is the most common trap. Computed properties look like a normal method on the component, but Livewire calls them from BaseComputed::evaluateComputed():

protected function evaluateComputed()
{
    return invade($this->component)->{parent::getName()}();
}

No wrap(), no container. As soon as you add a type-hinted parameter to the method, you get:

ArgumentCountError: Too few arguments to function App\Livewire\Dashboard::stats(),
0 passed and exactly 1 expected

This is a known regression from the Livewire v2 → v3 transition (there is a discussion #6967 about it) and in Livewire 4 it still has not changed.

2. Validation methods: rules(), messages(), validationAttributes()

The HandlesValidation trait calls these three methods:

if (method_exists($this, 'rules')) $rulesFromComponent = $this->rules();
if (method_exists($this, 'messages')) $messages = $this->messages();
if (method_exists($this, 'validationAttributes')) $validationAttributes = $this->validationAttributes();

If you wanted to get, say, a RuleFactory or a Translator into them through a type hint, you hit the same ArgumentCountError.

3. A few other places (less common)

For completeness: getListeners() (in SupportEvents) and queryString() (in SupportQueryString) are called through invade(), so also without DI. In practice that rarely matters, because you usually do not need to inject anything into them.

How to solve it

There are three options, ordered from best to worst.

Best practice: inject in boot(), store in a protected property

boot() runs on every request (initial and subsequent) and DI works in it. Protected properties are the ideal storage: Livewire does not serialize them to the client, so you do not have to deal with hydration.

<?php

namespace App\Livewire;

use App\Services\StatsService;
use Livewire\Attributes\Computed;
use Livewire\Component;

class Dashboard extends Component
{
    public string $apiKey;

    protected StatsService $stats;

    public function boot(StatsService $stats): void
    {
        $this->stats = $stats;
    }

    #[Computed]
    public function dailyRequests(): array
    {
        // $this->stats is available, no app() helper needed
        return $this->stats->forApiKey($this->apiKey);
    }

    public function render()
    {
        return view('livewire.dashboard');
    }
}

Why not mount()? Because mount() is called only when the component is first created. When a button is clicked (a subsequent request), the component is reconstructed, but mount() is not called and your protected property stays uninitialized → must not be accessed before initialization. boot() does not have this problem.

Second option: app() directly in the method

Simpler, but less testable and it violates inversion of control. It suits ad-hoc cases:

#[Computed]
public function dailyRequests(): array
{
    return app(StatsService::class)->forApiKey($this->apiKey);
}

Do not use: __construct in a Livewire component

Older tutorials sometimes show a solution with a constructor. Do not do it. Livewire components are not instantiated through new, but through the service container with reflection logic. Your own constructor will break it.

Summary

Place DI works? Reason
mount(), boot(), booted() ✅ called through wrap()
hydrate(), dehydrate(), their *Foo variants ✅ called through wrap()
updating(), updated(), *Foo variants ✅ called through wrap()
rendering(), rendered(), render() ✅ called through wrap()
exception() ✅ called through wrap()
Actions (public methods called from the front end) ✅ called through wrap()
Event listeners (#[On], getListeners) ✅ called through wrap()
with(), placeholder() ✅ called through wrap()
#[Computed] methods ❌ invade()
rules(), messages(), validationAttributes() ❌ direct $this->...()
getListeners(), queryString() ❌ invade()

If you need a service in a computed property or in validation rules, inject it in boot() into a protected property. It works, it is clean and you do not have to remember where Livewire uses wrap() internally and where it does not.