Building Web Apps with Laravel Module 11 – Inside the framework
The service container and dependency injection
Let Laravel 13's container build your classes, bind interfaces by environment, choose bind, singleton or scoped, inject config and swap fakes in tests.
What you will learn
- Explain what the container builds on its own, and why an interface or a string parameter needs a binding or a value
- Choose between bind, singleton and scoped for a service, and predict which object each queued job receives
- Configure a contextual binding and the
- Swap a real service for a fake or a Mockery mock in a feature test
Before you start
What you’ll learn · Before you start
What you will learn
- Explain what the container builds on its own, and why an interface or a string parameter needs a binding or a value
- Choose between bind, singleton and scoped for a service, and predict which object each queued job receives
- Configure a contextual binding and the
- Swap a real service for a fake or a Mockery mock in a feature test
Before you start
On this page
Pathshala sends a fee reminder from three places: a button in the back office, a nightly command and a queued job. If each of the three builds its own FeeReminder with new, each also builds the formatter and the SMS gateway inside it, so a change of SMS provider means editing three files, and a test has no way to stop real messages going out. Laravel’s service container: The object that builds and stores the classes an application needs. Laravel's application object is the container, and providers put their bindings into it. More in “How Laravel handles a request” builds that chain from the classes’ own constructors and hands it to whoever asks.
This lesson runs the container on Pathshala’s classes. You will see what it builds with no setup and the two things that stop it, bind an interface by environment, measure how long an object lives across three jobs of a real queue worker, give one class a different implementation from the rest, and replace the SMS gateway with a fake in a test.
The container builds a class by reading its constructor
Ask the container for a class, with app(FeeStatement::class) or by typing the class in a controller’s constructor, and it reads the constructor’s parameters through PHP’s reflection API. For each parameter typed with a class, it builds that class first, in the same way, and then calls the constructor with the results. This is autowiring: The container's way of building a class with no instructions: it reads the constructor with reflection, builds each parameter typed with a concrete class the same way, then calls the constructor. Laravel's documentation calls it zero-configuration resolution., which the Laravel documentation calls zero-configuration resolution: a class whose constructor asks only for other concrete classes needs no setup at all.
The probe below asks for three of Pathshala’s classes, none of them registered anywhere. A callback prints every object of the app as the container builds it:
"""Zero-configuration resolution: the container builds classes from their constructors, and stops at an interface or a
plain string it has no instructions for. The probe and the four classes are in the folder autowire/ next to this file."""
from session import run
run(
"""
php autowire_probe.php
""",
app=True,
overlay=("autowire",),
) Output
$ php autowire_probe.php make(FeeStatement) built App\Services\ReceiptFormatter built App\Services\FeeStatement result: Asha Verma: INR 1,180.00 due make(FeeReminder) built App\Services\ReceiptFormatter BindingResolutionException: Target [App\Contracts\SmsGateway] is not instantiable while building [App\Services\FeeReminder]. make(HttpSmsGateway) BindingResolutionException: Unresolvable dependency resolving [Parameter #0 [ <required> string $senderId ]] in class App\Services\HttpSmsGateway
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 autowire_session.py
FeeStatement worked. The container built the ReceiptFormatter its constructor asks for, then the statement itself, which printed Asha Verma: INR 1,180.00 due. The other two requests stopped with a BindingResolutionException: The exception Laravel's container throws when it cannot build what was asked for, such as an interface with no binding or a string parameter with no value. Its message names the type and the class being built., and their two messages name the two things autowiring cannot do on its own.
FeeReminder asks for an SmsGateway, which is an interface. PHP cannot create an object of an interface, and nothing in it says which class to use, so the message reads Target [App\Contracts\SmsGateway] is not instantiable while building [App\Services\FeeReminder]. Look at the line above it: the formatter was built before the container reached the interface, and was then thrown away. HttpSmsGateway asks for string $senderId. A string has no class to build and the container cannot guess which text you mean, so it reports an unresolvable dependency at parameter #0. Step through the second request:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A class is asked for. The probe calls make() with the FeeReminder class name. No provider binds it, so the container falls back to building the class itself.
- Reflection reads the constructor. PHP's reflection API lists the constructor's parameters and their types, a ReceiptFormatter and an SmsGateway. The container must have an object for each one before it can call new.
- The first parameter is easy. ReceiptFormatter is a concrete class with no constructor of its own, so the container builds one. The run printed this line, built App\Services\ReceiptFormatter, before anything failed.
- The second has no class. SmsGateway is an interface, and PHP cannot create an object of an interface. Without a binding the container has no class to build, so it stops here.
- The exception names both. The container throws a BindingResolutionException that names the interface and the class it was building. FeeReminder is never created, and the formatter built a moment earlier is thrown away.
- What a binding changes. Once a provider binds SmsGateway to a class, this step finds that class, builds it and moves on. The container then calls the constructor with both objects and returns the new FeeReminder.
Both failures are fixed by telling the container one more fact: a binding for the interface, and a value for the string. The next sections add each. Everything the framework builds for you goes down this same path: controllers, middleware, event listeners, the handle method of a queued job and the closure of an Artisan command all receive their parameters from the container.
Building a dependency with new inside the class
A constructor that runs new HttpSmsGateway('PTHSLA') itself hides the gateway from the container, so no binding and no test can ever replace it. Ask for the SmsGateway interface as a constructor parameter instead, and let a binding decide which class arrives.
An interface gets a binding that names its class
A container binding: A rule in the service container that says how to build a type, usually an interface: a class name or a closure, registered with bind(), singleton() or scoped() in a service provider, or written as a #[Bind] attribute on the interface. is a rule in the container that maps a type, usually an interface, to the class or the closure that builds it. The usual home is the register method of a service provider: A class that sets one part of the application up. Its register method binds services into the container, and its boot method uses them once every provider has registered. More in “How Laravel handles a request”, which runs before anything asks for a service; the request lesson shows why register comes before boot. Pathshala’s AppServiceProvider gets one line, $this->app->bind(SmsGateway::class, LogSmsGateway::class). From then on every constructor that asks for an SmsGateway receives a LogSmsGateway, which writes each message to the log instead of sending it.
Laravel 13 can also keep the binding on the interface itself. A #[Bind] PHP attribute: Metadata written in square brackets above a class, method or property, which the framework reads to change behaviour without a method or a property being written. More in “Form requests, custom rules and Precognition” names the class, and an optional environments: list limits it to the values of APP_ENV it names. Pathshala’s PaymentGateway carries two of them, a sandbox for local and testing and the live client for production:
binding/app/Providers/AppServiceProvider.php
<?php
namespace App\Providers;
use App\Contracts\SmsGateway;
use App\Services\LogSmsGateway;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// Whenever a constructor asks for an SmsGateway, build a LogSmsGateway.
$this->app->bind(SmsGateway::class, LogSmsGateway::class);
}
public function boot(): void
{
//
}
} binding/app/Contracts/PaymentGateway.php
<?php
namespace App\Contracts;
use App\Services\LivePaymentGateway;
use App\Services\SandboxPaymentGateway;
use Illuminate\Container\Attributes\Bind;
// The binding lives on the interface itself: the container reads these attributes the first time something asks for
// a PaymentGateway, and picks the line whose environments include the app's current one.
#[Bind(SandboxPaymentGateway::class, environments: ['local', 'testing'])]
#[Bind(LivePaymentGateway::class, environments: ['production'])]
interface PaymentGateway
{
public function charge(int $paise, string $reference): string;
} The probe resolves both in three environments. The session sets APP_ENV in the shell, which takes precedence over the value in .env:
"""Bindings: AppServiceProvider binds SmsGateway to LogSmsGateway, and PaymentGateway carries #[Bind] attributes that
pick a class by environment. The same probe runs in three environments. Files: the folder binding/ next to this file."""
from session import run
run(
"""
php binding_probe.php
export APP_ENV=production
php binding_probe.php
export APP_ENV=staging
php binding_probe.php
""",
app=True,
overlay=("binding",),
) Output
$ php binding_probe.php environment: local FeeReminder sends through LogSmsGateway PaymentGateway is SandboxPaymentGateway $ export APP_ENV=production $ php binding_probe.php environment: production FeeReminder sends through LogSmsGateway PaymentGateway is LivePaymentGateway $ export APP_ENV=staging $ php binding_probe.php environment: staging FeeReminder sends through LogSmsGateway BindingResolutionException: Target [App\Contracts\PaymentGateway] is not instantiable.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 binding_session.py
In local the reminder sends through LogSmsGateway and the payment gateway is the sandbox. With APP_ENV=production the provider’s binding is unchanged, and the attributes pick LivePaymentGateway. With APP_ENV=staging no attribute matches, and the interface is back to is not instantiable: an environment that the list forgot fails at its first payment, not at deploy time. Give such environments a default with a #[Bind] line that has no environments: list, or name every environment you deploy to.
Key idea
The container builds concrete classes by itself. An interface needs a binding, in a provider’s register method or as #[Bind] attributes on the interface, and a plain value such as a string needs to be told where it comes from.
Checkpoint
Check: What the container builds on its own · Binding an interface, in a provider or with
Bind, singleton and scoped decide how long an object lives
A binding also decides how long its object lives. bind() builds a new object every time something asks. A singleton: A binding whose object is built the first time something asks for it and then shared until the process ends, registered with singleton() or the #[Singleton] attribute. In a queue worker every job of the worker shares it., registered with singleton(), is built on the first ask and then returned for as long as the application lives. A scoped instance: An object the container shares within one request or one queued job and drops before the next one, registered with scoped() or the #[Scoped] attribute. Queue workers and Octane reset these between units of work. object, registered with scoped(), sits between the two: one object per request or per queued job, dropped before the next one starts. The attributes #[Singleton] and #[Scoped] on a class say the same without a line in a provider, and a class that was never registered behaves like bind().
Under PHP-FPM, the usual way PHP runs behind a web server, the application is built again for every request, so a singleton lives for one request and the difference from scoped never shows. It shows in a process that stays alive. A queue worker runs job after job in one booted application, and so does Octane for requests. The run below starts a real worker on three jobs. Each job asks twice for each service and records a letter for the object it got, the same letter for the same object:
lifetimes/app/Providers/AppServiceProvider.php
<?php
namespace App\Providers;
use App\Services\CentreDirectory;
use App\Services\JobLedger;
use App\Services\ReceiptFormatter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(ReceiptFormatter::class); // a new object every time (also what an unbound class gets)
$this->app->singleton(CentreDirectory::class); // one object for as long as the process runs
$this->app->scoped(JobLedger::class); // one object per request or per queued job
}
public function boot(): void
{
//
}
} lifetimes/app/Jobs/RecordLifetimes.php
<?php
namespace App\Jobs;
use App\Services\CentreDirectory;
use App\Services\JobLedger;
use App\Services\ReceiptFormatter;
use App\Support\Letters;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
// Asks the container twice for each of the three services and writes down which object it got each time.
class RecordLifetimes implements ShouldQueue
{
use Queueable;
public function __construct(public int $number)
{
}
public function handle(): void
{
$twice = fn (string $class) => Letters::of(app($class)).' '.Letters::of(app($class));
$line = sprintf(
'job %d bind: %s singleton: %s scoped: %s',
$this->number,
$twice(ReceiptFormatter::class),
$twice(CentreDirectory::class),
$twice(JobLedger::class),
);
file_put_contents(storage_path('app/lifetimes.txt'), $line.PHP_EOL, FILE_APPEND);
}
} """Lifetimes in a real queue worker: three jobs, each asking the container twice for a bind(), a singleton() and a
scoped() service. Letters stand for objects (the same object, the same letter). Files: the folder lifetimes/."""
from session import run
run(
"""
php lifetimes_probe.php
php artisan queue:work --stop-when-empty --quiet
cat storage/app/lifetimes.txt
""",
app=True,
overlay=("lifetimes",),
) Output
$ php lifetimes_probe.php jobs waiting on the queue: 3 $ php artisan queue:work --stop-when-empty --quiet $ cat storage/app/lifetimes.txt job 1 bind: A B singleton: C C scoped: D D job 2 bind: E F singleton: C C scoped: G G job 3 bind: H I singleton: C C scoped: J J
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lifetimes_session.py
Read the output column by column. bind() handed out six formatters for six asks, A to I without one repeat. The singleton is C in every job: the worker built one CentreDirectory and shared it with all three. The scoped ledger is one object within a job, D and D, and a new object in each job, D, G and J. The worker drops it on purpose: before it takes each job it calls forgetScopedInstances() on the container, as the 13.x source of QueueServiceProvider shows. Step through the same run:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The worker starts. php artisan queue:work boots the application once and keeps it in memory. Every job it takes runs inside this one process, with this one container.
- Job 1 asks twice for each. The first job asks the container for each service two times. bind() hands out a new formatter on every ask, A and then B; the singleton is C; the scoped ledger is D both times.
- The worker resets the scope. Before it takes the next job, the worker calls forgetScopedInstances(). The container drops the scoped ledger D and keeps the singleton C.
- Job 2 gets a new ledger. The second job receives E and F from bind() and the same C from the singleton. The scoped ledger is a new object, G, shared by both of its asks.
- Job 3 repeats the pattern. The third job gets H and I, C again, and a fresh ledger J. Across the three jobs the run printed six formatters, one directory and three ledgers.
Choose a lifetime from what the object holds. A service that only reads settings or computes may be a singleton when it is costly to build, and needs no registration when it is cheap. A service that holds anything about the current request or job must never be a singleton. Answer the two questions for one of your own services:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Lifetime | Holds per-job state | Costly to build | In a long-running worker |
|---|---|---|---|
| New each time Registered with: bind(), or no registration at all | Good every ask gets a fresh object, so nothing carries over | Poor built again on every ask: the run made 6 formatters for 3 jobs | Good nothing outlives the code that asked for it |
| One per process Registered with: singleton(), or #[Singleton] on the class | Poor one object for every job: the run leaked Nehru Nagar into the summary | Good built once, on the first ask: one directory, C, for 3 jobs | Mixed lives as long as the worker, so it must hold nothing about one job |
| One per request or job Registered with: scoped(), or #[Scoped] on the class | Good dropped between jobs: the ledgers were D, G and J | Mixed built once per job, not once per ask | Good the worker resets it before each job |
How the answers decide
- “Does the object keep data about one request or one job?” Yes, such as the current centre or a running total: rules out One per process. A singleton outlives the job that filled it, so the next job of the same worker reads the old value.
- “Does the object keep data about one request or one job?” Yes, such as the current centre or a running total and “Is it costly to build, or should the code inside one job share one copy?” Yes, build it once and share it: adds a caveat to New each time. A new object on every ask cannot be shared by two classes inside one job; scoped gives one copy per job.
- “Does the object keep data about one request or one job?” No, it only reads settings or computes and “Is it costly to build, or should the code inside one job share one copy?” Yes, build it once and share it: adds a caveat to New each time and One per request or job. With nothing about one job inside it, a single shared copy is safe, and building it again is wasted work.
- “Does the object keep data about one request or one job?” No, it only reads settings or computes and “Is it costly to build, or should the code inside one job share one copy?” No, a fresh one each time is fine: adds a caveat to One per process and One per request or job. A small object with no state needs no registration at all: the default builds a new one each time.
Key idea
A singleton lives as long as the application that built it, and a scoped object as long as one request or job. In a queue worker, which keeps one application for every job, that difference decides whether one job can see the data of another.
A singleton that holds job state leaks into the next job
The danger in that table is easy to write by accident. CurrentCentre remembers which centre the current work is about, so that code deep inside a job can ask for it instead of passing the centre through every call. It was marked #[Singleton] so that every class inside a job would share one object. The reminders job sets it to Nehru Nagar, and the daily summary, which should cover all three centres, runs next in the same worker:
leak/app/Support/CurrentCentre.php
<?php
namespace App\Support;
use Illuminate\Container\Attributes\Singleton;
// Which centre the current piece of work is about. Marked #[Singleton]: one object for the whole worker process.
#[Singleton]
class CurrentCentre
{
private ?string $name = null;
public function set(string $name): void
{
$this->name = $name;
}
public function name(): ?string
{
return $this->name;
}
} leak/app/Jobs/SendDailySummary.php
<?php
namespace App\Jobs;
use App\Support\CurrentCentre;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
// The daily summary covers every centre, unless some code has narrowed the work down to one.
class SendDailySummary implements ShouldQueue
{
use Queueable;
public function handle(CurrentCentre $current): void
{
$line = $current->name() === null
? 'summary job: no centre set, so it covers all 3 centres'
: "summary job: covers only {$current->name()}";
file_put_contents(storage_path('app/centre.log'), $line.PHP_EOL, FILE_APPEND);
}
} """A singleton that holds the state of one job leaks it into the next job of the same worker. The session runs the
two jobs with CurrentCentre marked #[Singleton], copies in the version marked #[Scoped], and runs them again.
Files: the folder leak/ next to this file (leak/fixed/ holds the scoped version)."""
from session import run
run(
"""
php leak_probe.php
php artisan queue:work --stop-when-empty --quiet
cat storage/app/centre.log
cp fixed/app/Support/CurrentCentre.php app/Support/CurrentCentre.php
php leak_probe.php
php artisan queue:work --stop-when-empty --quiet
cat storage/app/centre.log
""",
app=True,
overlay=("leak",),
) Output
$ php leak_probe.php queued: reminders for Nehru Nagar, then the daily summary $ php artisan queue:work --stop-when-empty --quiet $ cat storage/app/centre.log reminders job: centre set to Nehru Nagar summary job: covers only Nehru Nagar $ cp fixed/app/Support/CurrentCentre.php app/Support/CurrentCentre.php $ php leak_probe.php queued: reminders for Nehru Nagar, then the daily summary $ php artisan queue:work --stop-when-empty --quiet $ cat storage/app/centre.log reminders job: centre set to Nehru Nagar summary job: no centre set, so it covers all 3 centres
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 leak_session.py
With #[Singleton] the summary job printed covers only Nehru Nagar. The object the first job filled was still in the container, so a report meant for every centre quietly covered one. The bug hides in two places: on a laptop, where each job may run in its own php artisan call, and under PHP-FPM, where every request starts empty. It appears only in a long-running process, which is exactly where queued jobs run in production.
After the session copies in the version marked #[Scoped], the same two jobs print no centre set, so it covers all 3 centres. Nothing else changed: the worker dropped the first job’s object before the second job began, as it did with the ledger in the previous run.
Clearing the value at the end of the job instead
A line that resets CurrentCentre at the end of handle() works until a job throws before it reaches that line, and then the next job inherits the stale centre. Mark the class #[Scoped] and let the worker’s reset clear it before every job, whether the last one succeeded or failed.
Checkpoint
Check: bind, singleton and scoped lifetimes · State in a singleton leaking between jobs
Contextual bindings and attributes give one class what it needs
Sometimes one class needs a different implementation from everyone else. Pathshala’s sign-in codes must reach a parent within seconds, while fee reminders can wait in a cheap bulk route. Both FeeReminder and OtpSender ask for an SmsGateway, and a contextual binding: A binding that applies only while the container builds one named class, such as when(OtpSender::class)->needs(SmsGateway::class)->give(...), or #[Give] on the parameter. For that class it wins over the plain binding. gives each its own: $this->app->when(OtpSender::class)->needs(SmsGateway::class)->give(PrioritySmsGateway::class). The #[Give] attribute writes the same rule on the parameter, as ResetCodeSender does below. When the container builds a class, it checks for such a rule before it looks at the plain binding:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- The default binding. FeeReminder asks for an SmsGateway. No rule names FeeReminder, so the container uses the plain binding of AppServiceProvider and builds a BulkSmsGateway.
- A rule for one class. OtpSender asks for the same interface. The provider's when(OtpSender)->needs(SmsGateway) rule matches this class, and the container checks that rule before the plain binding, so OtpSender gets a PrioritySmsGateway.
- The rule on the parameter. ResetCodeSender writes the same choice as
- One interface, two classes. All three constructors type the same interface, and none of them names a class. The bindings decide, so the route a message takes can change without editing any of the three.
Laravel 13 has a family of these container attribute: A PHP attribute on a constructor or method parameter that tells Laravel's container where the value comes from, such as #[Config('key')], #[CurrentUser], #[Storage('disk')] or #[Give(SomeClass::class)]. for parameters, so a constructor can say where each value comes from. #[Config('services.sms.sender_id')] string $senderId fills a string from a config file, which fixes the third failure of the first probe. #[CurrentUser] User $author injects the signed-in user. The others follow the same pattern. #[Storage('receipts')] injects a filesystem disk, and #[Cache], #[DB] and #[Log] a named store, connection or channel. #[Context('request_id')] reads a value of the request’s context, #[RouteParameter('student')] a route parameter, and #[Tag('reports')] every service tagged with that name. The run resolves the classes of this section:
contextual/app/Providers/AppServiceProvider.php
<?php
namespace App\Providers;
use App\Contracts\SmsGateway;
use App\Services\BulkSmsGateway;
use App\Services\OtpSender;
use App\Services\PrioritySmsGateway;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// Every class that asks for an SmsGateway gets the bulk route ...
$this->app->bind(SmsGateway::class, BulkSmsGateway::class);
// ... except OtpSender: sign-in codes must not wait behind a batch of fee reminders.
$this->app->when(OtpSender::class)
->needs(SmsGateway::class)
->give(PrioritySmsGateway::class);
}
public function boot(): void
{
//
}
} contextual/app/Services/ResetCodeSender.php
<?php
namespace App\Services;
use App\Contracts\SmsGateway;
use Illuminate\Container\Attributes\Give;
// Sends password-reset codes. The same choice as OtpSender, written on the parameter: #[Give] names the class.
class ResetCodeSender
{
public function __construct(
#[Give(PrioritySmsGateway::class)] private SmsGateway $sms,
) {
}
public function describe(): string
{
return 'ResetCodeSender sends through '.class_basename($this->sms);
}
} contextual/app/Services/HttpSmsGateway.php
<?php
namespace App\Services;
use App\Contracts\SmsGateway;
use Illuminate\Container\Attributes\Config;
// The class that stopped the container earlier: #[Config] now tells it where the string comes from.
class HttpSmsGateway implements SmsGateway
{
public function __construct(
#[Config('services.sms.sender_id')] private string $senderId,
) {
}
public function send(string $to, string $text): string
{
return "queued at the provider as {$this->senderId}";
}
public function describe(): string
{
return "HttpSmsGateway sends as {$this->senderId}";
}
} contextual/app/Services/StaffNotice.php
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
// Puts the name of whoever is signed in on a notice: #[CurrentUser] injects the authenticated user.
class StaffNotice
{
public function __construct(#[CurrentUser] private User $author)
{
}
public function describe(): string
{
return $this->author->exists
? "notice from {$this->author->name}"
: 'notice from an empty User the container built (name: '.var_export($this->author->name, true).')';
}
} """Contextual binding and parameter attributes: OtpSender gets another SmsGateway than every other class, #[Give]
does the same on a parameter, #[Config] fills a string from config/services.php and #[CurrentUser] injects the
signed-in user. Files: the folder contextual/ next to this file."""
from session import run
run(
"""
php contextual_probe.php
""",
app=True,
overlay=("contextual",),
) Output
$ php contextual_probe.php FeeReminder sends through BulkSmsGateway OtpSender sends through PrioritySmsGateway ResetCodeSender sends through PrioritySmsGateway HttpSmsGateway sends as PTHSLA nobody signed in constructor, User: notice from an empty User the container built (name: NULL) constructor, ?User: null method call, ?User: null Asha signed in constructor, User: notice from Asha Verma constructor, ?User: a User, exists: true method call, ?User: a User, exists: true
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 contextual_session.py
The first four lines are the routes and the sender id: the bulk route for FeeReminder, the priority route for both code senders, and PTHSLA, the value of services.sms.sender_id in this project’s config/services.php. The two blocks after them hold the surprise of the run. With nobody signed in, #[CurrentUser] has no user to give, and a constructor parameter typed User does not fail: the container falls back to building a new, empty User, and the notice names an author whose name is NULL. A constructor parameter written ?User $author = null receives its default, null, and so does a nullable parameter of a method that the container calls, such as a controller action. Once Asha is signed in, all three receive her saved record.
A non-nullable #[CurrentUser] User in a constructor
When nobody is signed in, #[CurrentUser] User $author in a constructor becomes an empty model that passes every type check. Write #[CurrentUser] ?User $author = null and handle null, or build the class only on routes behind the auth middleware.
A test swaps the binding for a fake or a mock
The bindings are also what make a class easy to test. A feature test runs inside the same container as the app, so it can put an object of its own in place of the real one before the code under test asks for it. $this->instance(SmsGateway::class, $fake) stores the fake as a ready-made instance, which wins over the provider’s bind() or singleton() of that type but not over a contextual binding, so OtpSender would still get its priority gateway. $this->swap() does the same: in 13.34 it simply calls instance(). $this->mock(SmsGateway::class, ...) builds a Mockery mock and binds it in one step, so the test can state the exact call it expects.
Pathshala’s test does both against a fees:remind command, whose closure receives its FeeReminder from the container:
swap/tests/Feature/FeeReminderTest.php
<?php
namespace Tests\Feature;
use App\Contracts\SmsGateway;
use App\Services\FeeReminder;
use Mockery\MockInterface;
use Tests\Fakes\FakeSmsGateway;
use Tests\TestCase;
class FeeReminderTest extends TestCase
{
public function test_the_command_sends_one_message_per_due_instalment(): void
{
// Put the fake into the container before the command asks for an SmsGateway.
$fake = new FakeSmsGateway();
$this->instance(SmsGateway::class, $fake);
$this->artisan('fees:remind')
->expectsOutput('Asha Verma: fake-1')
->assertSuccessful();
$this->assertCount(2, $fake->sent);
$this->assertSame('+91 9800000002', $fake->sent[1]['to']);
$this->assertSame('Kabir Singh: INR 950.00 due', $fake->sent[1]['text']);
}
public function test_a_mock_checks_the_exact_call(): void
{
// mock() builds a Mockery mock and binds it in the container in one step.
$this->mock(SmsGateway::class, function (MockInterface $mock) {
$mock->shouldReceive('send')
->once()
->with('+91 9800000001', 'Asha Verma: INR 1,180.00 due')
->andReturn('mock-1');
});
$this->assertSame('mock-1', app(FeeReminder::class)->remind('+91 9800000001', 'Asha Verma', 118000));
}
} routes/console.php · swap/routes/console.php
<?php
use App\Services\FeeReminder;
use Illuminate\Support\Facades\Artisan;
// Sends a reminder for each instalment that is due. A closure command gets its parameters from the container.
Artisan::command('fees:remind', function (FeeReminder $reminder) {
$due = [
['phone' => '+91 9800000001', 'student' => 'Asha Verma', 'paise' => 118000],
['phone' => '+91 9800000002', 'student' => 'Kabir Singh', 'paise' => 95000],
];
foreach ($due as $row) {
$id = $reminder->remind($row['phone'], $row['student'], $row['paise']);
$this->line("{$row['student']}: {$id}");
}
})->purpose('Send fee reminders for the instalments that are due'); swap/tests/Fakes/FakeSmsGateway.php
<?php
namespace Tests\Fakes;
use App\Contracts\SmsGateway;
// A test double: keeps every message in an array instead of sending it, so a test can read what was sent.
class FakeSmsGateway implements SmsGateway
{
/** @var list<array{to: string, text: string}> */
public array $sent = [];
public function send(string $to, string $text): string
{
$this->sent[] = ['to' => $to, 'text' => $text];
return 'fake-'.count($this->sent);
}
} """Swapping in tests: the real command sends through LogSmsGateway (bound in AppServiceProvider); the feature test
puts a fake into the container with instance(), and a Mockery mock with mock(). Files: the folder swap/."""
from session import run
run(
"""
export COLLISION_IGNORE_DURATION=true
php artisan fees:remind
php artisan test --filter=FeeReminderTest
""",
app=True,
overlay=("swap",),
) Output
$ export COLLISION_IGNORE_DURATION=true $ php artisan fees:remind Asha Verma: logged Kabir Singh: logged $ php artisan test --filter=FeeReminderTest PASS Tests\Feature\FeeReminderTest ✓ the command sends one message per due instalment ✓ a mock checks the exact call Tests: 2 passed (7 assertions) Duration: …s
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 swap_session.py
Run for real, the command printed logged twice, because AppServiceProvider binds the log gateway. In the first test the same command sent both reminders to the fake, and the test read them back: two messages, the second to +91 9800000002 with the text Kabir Singh: INR 950.00 due. The second test asserted a call rather than a result. The mock expected exactly one send with Asha’s number and text, and Mockery would have failed the test on a second call or on any other arguments. Both tests passed, with 7 assertions between them, and neither one reached a real SMS provider.
Checkpoint
Check: Contextual bindings and parameter attributes · Swapping a binding in a test
Practise
Wire a payment gateway through the container in your own Pathshala project, one step at a time:
- Create the interface
App\Contracts\PaymentGatewaywithcharge(int $paise, string $reference): string, and two classes that implement it,SandboxPaymentGatewayandCardPaymentGateway. - Add
'payments' => ['driver' => env('PAYMENTS_DRIVER', 'sandbox')]toconfig/services.php. InAppServiceProvider::register, bind the interface with a closure that readsconfig('services.payments.driver')and builds the matching class. - Give
CardPaymentGatewayits merchant id with#[Config('services.payments.merchant_id')], and check inphp artisan tinkerthatapp(PaymentGateway::class)followsPAYMENTS_DRIVER. - Write a feature test that sets
config(['services.payments.driver' => 'card'])and asserts the class it resolves, and a second test that puts a mock in the container which expects exactly onecharge.
The self-check below picks each question from your earlier answers, so a part you missed comes back in another form.
Practice that adapts to you
Practise this lesson
12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what does Laravel’s service container do when a constructor asks for a class, and in which two cases does it need something from you?
It reads the constructor with PHP’s reflection API, builds every parameter typed with a concrete class in the same way, and then calls the constructor with the results. Nothing has to be registered for that, which the documentation calls zero-configuration resolution. It needs your help in two cases. A parameter typed with an interface has no class to build, so you bind the interface to a class in a service provider’s register method, or with a #[Bind] attribute on the interface. A parameter typed as a string, an integer or another plain value has nothing to build at all, so you give it a value: a default in the signature, a closure binding, a contextual give(), or an attribute such as #[Config('services.sms.sender_id')]. Without one, the container throws a BindingResolutionException that names the parameter and the class it was building.
Two questions take the container past this lesson’s examples:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Pathshala's GuardianNotifier now takes AttendanceAlerts in its constructor, and AttendanceAlerts takes GuardianNotifier. Neither is bound anywhere. Say what happens when a controller asks for either one, how you would find the cause, and how you would restructure the two classes.
-
Mid to staff
Each of Pathshala's three centres has its own merchant account with a card provider, and one centre still uses the sandbox. One queued job charges instalments for any centre. Design how code gets the right PaymentGateway for a given centre, and say what a container binding alone cannot do here.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How does Laravel's service container build a class that was never registered?
It reads the constructor's parameters through PHP's reflection API, builds each class-typed parameter first in the same way, then calls the constructor with the results. This is autowiring: a class that asks only for concrete classes needs no setup.
-
Which two kinds of constructor parameter make autowiring stop with a BindingResolutionException?
An interface, such as SmsGateway, because PHP cannot create one and nothing says which class to use (is not instantiable); and a plain value such as string $senderId, because there is no class to build (an unresolvable dependency).
-
Where does an interface get its binding, and what does Laravel 13 add?
Usually in a service provider's register method, such as $this->app->bind(SmsGateway::class, LogSmsGateway::class). Laravel 13 can also put Bind attributes on the interface, each with an optional environments list of APP_ENV values; an environment no attribute names fails at first use, so add one Bind line without a list as the default.
-
How long does an object live under bind(), singleton() and scoped()?
bind() builds a new object on every ask. singleton() builds it on the first ask and returns that object for as long as the application lives: one request under PHP-FPM, every job in a queue worker. scoped() keeps one object per request or queued job; a queue worker calls forgetScopedInstances() before taking each job.
-
Why did a daily summary job cover only one centre in a queue worker?
CurrentCentre was a singleton, so the centre an earlier job set was still in the container. Marked Scoped, the worker cleared it before the next job. Resetting it at the end of handle() fails as soon as a job throws before that line.
-
How does one class get a different implementation of an interface from everyone else?
A contextual binding: $this->app->when(OtpSender::class)->needs(SmsGateway::class)->give(PrioritySmsGateway::class), or the Give attribute on the parameter. The container checks for such a rule before the plain binding.
-
What do the Config and CurrentUser parameter attributes inject, and what is the trap?
Config('services.sms.sender_id') fills a string from a config file; CurrentUser injects the signed-in user. With nobody signed in, a parameter typed User receives a new, empty User instead of failing, so write ?User $author = null and handle null.
-
How does a feature test replace a container binding?
$this->instance(SmsGateway::class, $fake) stores a ready object that wins over the provider's bind() or singleton() but not over a contextual binding; swap() does the same; mock() builds a Mockery mock and binds it, so the test can state the exact calls it expects.
Key takeaways
- The container builds a class by reading its constructor and building each class-typed parameter first, so a class that depends only on concrete classes needs no registration.
- An interface needs a binding, in a provider’s register method or as #[Bind] attributes on the interface, and a string or another plain value needs a value, or the container throws a BindingResolutionException.
bind()gives a new object on every ask,singleton()one object per process andscoped()one per request or job, and a queue worker drops the scoped objects before every job it takes.- A singleton that holds the state of one job leaks it into the next job of the same worker, so state about the current job or request belongs in a scoped object.
- A contextual binding or
#[Give]gives one class its own implementation,#[Config]fills a plain value from config, and#[CurrentUser]in a constructor typedUserreceives an empty model when nobody is signed in. - A feature test replaces a binding with
$this->instance(),$this->swap()or$this->mock(), so the code under test receives the fake through the container without changing a line of it.
Terms in this lesson
- autowiring
- The container's way of building a class with no instructions: it reads the constructor with reflection, builds each parameter typed with a concrete class the same way, then calls the constructor. Laravel's documentation calls it zero-configuration resolution.
- BindingResolutionException
- The exception Laravel's container throws when it cannot build what was asked for, such as an interface with no binding or a string parameter with no value. Its message names the type and the class being built.
- container attribute
- A PHP attribute on a constructor or method parameter that tells Laravel's container where the value comes from, such as #[Config('key')], #[CurrentUser], #[Storage('disk')] or #[Give(SomeClass::class)].
- container binding
- A rule in the service container that says how to build a type, usually an interface: a class name or a closure, registered with bind(), singleton() or scoped() in a service provider, or written as a #[Bind] attribute on the interface.
- contextual binding
- A binding that applies only while the container builds one named class, such as when(OtpSender::class)->needs(SmsGateway::class)->give(...), or #[Give] on the parameter. For that class it wins over the plain binding.
- scoped instance
- An object the container shares within one request or one queued job and drops before the next one, registered with scoped() or the #[Scoped] attribute. Queue workers and Octane reset these between units of work.
- singleton
- A binding whose object is built the first time something asks for it and then shared until the process ends, registered with singleton() or the #[Singleton] attribute. In a queue worker every job of the worker shares it.
References
- Service Container (Laravel (laravel.com))
- Service Providers (Laravel (laravel.com))
- Mocking (Laravel (laravel.com))
- Queues (running the queue worker) (Laravel (laravel.com))
- Illuminate\Container\Container (13.x source) (Laravel (github.com/laravel))
- Illuminate\Queue\QueueServiceProvider (13.x source) (Laravel (github.com/laravel))
- Reflection (The PHP Group (php.net))
- Attributes overview (The PHP Group (php.net))
Report a problem with this lesson
Kept only in this browser. Your Learn progress