skip to content

In a Laravel newsletter app, why does resolving ClickTracker inside NewsletterServiceProvider::register() to build a Mailable macro fail, and how is it fixed?

level: middleimportance: should knowfreq 45%

answer

  1. register() runs provider by provider
  2. the later provider has not bound it
  3. Target [...] is not instantiable
  4. move the macro into boot()
  5. resolve lazily inside the closure

basics

~20 s

Providers register one at a time, so when NewsletterServiceProvider::register() runs, the provider that binds ClickTracker may not have run yet and resolving it throws. Define the macro in boot() and resolve ClickTracker inside the macro closure.

solid answer

~40 s

Laravel calls every provider's `register()` in list order, and `make:provider` rewrites `bootstrap/providers.php` alphabetically, so `NewsletterServiceProvider` registers before `TrackingServiceProvider`. Calling `$this->app->make(ClickTracker::class)` in `register()` asks for an interface nobody has bound yet, and startup fails with `BindingResolutionException: Target [App\Contracts\ClickTracker] is not instantiable`. If the dependency were a concrete class it would be worse: auto-wiring would quietly build a separate instance, and the singleton registered later would never reach the macro. The fix is to define the macro in `boot()`, which runs after every provider has registered, and to call `app(ClickTracker::class)` inside the macro's closure so the tracker is fetched when the macro runs, not when it is defined.

code

php · 20 lines
php
<?php

namespace App\Providers;

use App\Contracts\ClickTracker;
use Illuminate\Mail\Mailable;
use Illuminate\Support\ServiceProvider;

class NewsletterServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Broken: TrackingServiceProvider has not registered yet.
        $tracker = $this->app->make(ClickTracker::class);

        Mailable::macro('tracked', function () use ($tracker) {
            return $this->withSymfonyMessage(fn ($message) => $tracker->rewriteLinks($message));
        });
    }
}

go deeper

for a junior

Recall that register() is for bindings only and that anything using another service, including macros, belongs in boot().

for a middle

Explain the one-at-a-time register pass, the provider order in bootstrap/providers.php, and why an interface resolved there throws 'not instantiable'.

for a senior

Recognise the silent duplicate-instance variant, fix both the phase and the eager capture, and remove order dependencies instead of reordering providers.

for a principal

Set review rules for providers, such as no make() in register() and no captured services in macros, so this class of bug cannot reach production.

## The scenario A newsletter platform has two app providers: - `TrackingServiceProvider` binds the `ClickTracker` interface to a `RedisClickTracker` singleton; - `NewsletterServiceProvider` adds a `Mailable::macro('tracked', ...)` so every newsletter email can rewrite its links through the tracker. The first version put the macro, and the tracker lookup, in `NewsletterServiceProvider::register()`. The app now fails to start in every entry point: web requests, Artisan and queue workers. ## Why register() is the wrong place 1. At startup Laravel registers the framework's providers, then package providers, then the app's providers from `bootstrap/providers.php`, **one at a time**. 2. `make:provider` rewrites that file in alphabetical order, so `NewsletterServiceProvider` comes before `TrackingServiceProvider`. 3. `NewsletterServiceProvider::register()` calls `$this->app->make(ClickTracker::class)`. 4. `ClickTracker` is an interface and `TrackingServiceProvider::register()` has not run, so there is no binding. The container throws `Target [App\Contracts\ClickTracker] is not instantiable`. The Laravel documentation warns about exactly this: `register()` should only bind things, "otherwise, you may accidentally use a service that is provided by a service provider which has not loaded yet." ## The silent variant If the macro had asked for a **concrete** class instead of an interface, nothing would throw: - auto-wiring builds a fresh `RedisClickTracker` on the spot; - the macro's closure captures that object; - `TrackingServiceProvider` then registers its own singleton, configured differently. The app now holds **two** trackers, and clicks recorded through the macro go to the unconfigured one. Bugs like this survive until someone notices missing statistics, which is worse than a startup crash. ## The fix ```php public function register(): void { // only bindings here, if any } public function boot(): void { Mailable::macro('tracked', function () { $tracker = app(ClickTracker::class); // resolved when the macro runs return $this->withSymfonyMessage(fn ($message) => $tracker->rewriteLinks($message)); }); } ``` Two changes, each doing a separate job: - **Moving to `boot()`** guarantees every provider has registered, whatever their order. - **Resolving inside the closure** defers the lookup until a mailable actually calls `tracked()`, so the macro always uses the current binding. ## Why resolve inside the closure - It costs nothing at startup; the tracker is only built when an email is sent. - It uses whatever `ClickTracker` is bound at call time, including one swapped in by a test. - It keeps the macro free of objects captured during bootstrap, which matters once the same process handles many requests or jobs. ## Diagnosing it in a running app 1. The failure happens during bootstrap, so **every** entry point breaks: web requests, `php artisan` commands and queue workers. The `/up` health route answers 500 because the application cannot boot. 2. The stack trace runs through `Application::register()` into your provider's `register()` method; that frame names the culprit. 3. The message `Target [...] is not instantiable` names the interface nobody had bound at that moment. 4. Search the providers for the binding; if it lives in a provider that registers later, the fix is the phase, not the binding. 5. For the silent variant, compare object identities: `spl_object_id()` of the tracker inside the macro versus `app(ClickTracker::class)` elsewhere reveals the duplicate. ## A checklist for register() - Call only `bind()`, `singleton()`, `scoped()`, `instance()`, `when()` rules and `$this->app->register()` for sub-providers. - Never call `make()`, `app(...)` or a facade that resolves a service. - Reading `config()` is safe, because configuration is loaded before any provider registers, but reading it inside a binding closure keeps registration lazier. - Anything that **does** something, a macro, listener, route, gate or composer, goes in `boot()`.

  • In Laravel, would renaming the provider so it sorts after TrackingServiceProvider be an acceptable fix?
    It would stop the crash but keep a hidden order dependency: the next provider added, a package update or a manual edit of `bootstrap/providers.php` could break it again. Moving the work into `boot()` removes the dependency entirely, because every provider has registered before any boot runs.
  • In a Laravel provider, is it safe to call config() inside register()?
    Yes. The configuration bootstrapper runs before providers register, so config values are available. It is still better to read them inside a binding closure, so the value is taken when the service is built and the provider does no work at registration.

saying these in an interview costs you the question

  • Provider order never matters, so resolving services in register() is always safe.
  • The fix is to list TrackingServiceProvider first and leave the code in register().
  • A concrete class resolved early in register() is the same object as the singleton registered later.
  • Macros must be defined in register() so they exist before routes load.
  • Calling config() inside register() fails because configuration is not loaded yet.