Service container and dependency injection: a deep dive
Laravel's service container is the beating heart of the framework—it manages object creation, wires up dependencies, and keeps your code loosely coupled. Most developers use it without thinking much about it, but understanding how it resolves your classes transforms you from someone who follows patterns to someone who shapes them. In this article, we'll move beyond "just type-hint" and explore the mechanics that make dependency injection in Laravel actually work.
What the service container actually does
Before we talk about injection, let's be clear: the service container is a registry and a factory. It stores instructions for how to create objects, and when you ask it for an instance, it follows those instructions. Think of it as a blueprint warehouse. You tell it "when someone asks for a Database connection, create one using this configuration," and it remembers that mapping. Later, when a controller needs a Database, the container fetches the blueprint and builds the object.
This matters because it decouples your code from the specifics of how things are created. Your controller doesn't know whether the database is MySQL or PostgreSQL. Your payment handler doesn't know if you're using Stripe or PayPal. The container decides that at runtime.
Automatic injection — Type-hint dependencies in constructors
The easiest pattern Laravel provides is constructor injection. When a class needs something, you type-hint it in the constructor, and Laravel automatically resolves it.
namespace App\Http\Controllers;
use App\Services\OrderService;
use Illuminate\Http\Request;
class OrderController extends Controller
{
public function __construct(private OrderService $orderService) {}
public function store(Request $request)
{
$order = $this->orderService->createFromRequest($request);
return response()->json($order);
}
}When Laravel routes a request to OrderController, it inspects the constructor. It sees OrderService, looks in the container, and instantiates it. The controller never calls new OrderService(). Laravel does. This is powerful because:
- The controller is testable—you can pass a mock
OrderServicein tests. - If
OrderServiceitself needs dependencies, the container recursively resolves them. - You can swap implementations without touching the controller.
Laravel's reflection API walks the constructor signature and pulls out type-hints. If a parameter is typed (like OrderService above), Laravel tries to resolve it. If it's not typed or is a primitive like string, Laravel skips it—though you can still bind those via explicit registration.
Bind interfaces — Map an interface to an implementation
Type-hinting the concrete class works, but it couples you to that implementation. The better pattern is to type-hint an interface and tell the container which implementation to use. This is where flexibility lives.
Say you're building a notification system. You might have:
namespace App\Contracts;
interface NotificationService
{
public function send(string $to, string $message): bool;
}And two implementations:
namespace App\Services;
use App\Contracts\NotificationService;
class EmailNotificationService implements NotificationService
{
public function send(string $to, string $message): bool
{
// Send via email
return true;
}
}
class SmsNotificationService implements NotificationService
{
public function send(string $to, string $message): bool
{
// Send via SMS
return true;
}
}Now register one in a service provider:
namespace App\Providers;
use App\Contracts\NotificationService;
use App\Services\EmailNotificationService;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(
NotificationService::class,
EmailNotificationService::class
);
}
}Your consumer doesn't care which implementation it gets:
namespace App\Jobs;
use App\Contracts\NotificationService;
class SendWelcomeEmail
{
public function __construct(private NotificationService $notifier) {}
public function handle(): void
{
$this->notifier->send('user@example.com', 'Welcome!');
}
}When you need to switch from email to SMS, you change one line in the service provider, not everywhere the notifier is used. In production, you might even bind based on environment or configuration:
$this->app->bind(
NotificationService::class,
config('services.notification.driver') === 'sms'
? SmsNotificationService::class
: EmailNotificationService::class
);This is also where closures shine. Instead of pointing to a class, you can bind a closure that performs custom logic:
$this->app->bind(NotificationService::class, function ($app) {
if (app()->environment('testing')) {
return new LoggingNotificationService();
}
return new EmailNotificationService();
});Singletons — Share one instance for clients and configuration
Sometimes you want the container to create an object once and reuse it everywhere. That's a singleton. Database connections, configuration repositories, and cache managers are typical singletons.
Use singleton() instead of bind():
namespace App\Providers;
use App\Services\ClientRegistry;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(ClientRegistry::class, function ($app) {
$registry = new ClientRegistry();
$registry->load(config('clients.data'));
return $registry;
});
}
}Now, every time the container resolves ClientRegistry, it gets the same instance. The registry is loaded once, and state is shared. This matters for performance and correctness—you don't want to reload client data on every request.
You can also use the shorthand:
$this->app->singleton(ClientRegistry::class);This works if ClientRegistry has no constructor dependencies or they're already resolvable. For more complex scenarios, use the closure form.
A word of caution: be careful with mutable state in singletons. If the registry loads data once but that data changes mid-request, you might hit stale data. For stateless services (like logging or metrics), singletons are perfect. For stateful things, think hard about lifecycle.
Facades vs injection — Injection makes dependencies visible and testable
Laravel provides facades like Cache::get(), Log::info(), and Queue::push(). They're convenient—a static call instead of constructing or injecting. But they hide dependencies.
Consider this facade usage:
namespace App\Jobs;
use Illuminate\Support\Facades\Cache;
class UpdateUserStats
{
public function handle(): void
{
$stats = $this->computeStats();
Cache::put('user-stats', $stats, 3600);
}
private function computeStats(): array
{
// Heavy computation
return [];
}
}The cache dependency is invisible. Someone reading this code doesn't immediately see that it depends on caching. And testing is awkward—you need to mock the facade:
use Illuminate\Support\Facades\Cache;
Cache::shouldReceive('put')
->once()
->with('user-stats', \Mockery::any(), 3600);
$job = new UpdateUserStats();
$job->handle();Now compare with injection:
namespace App\Jobs;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
class UpdateUserStats
{
public function __construct(private CacheRepository $cache) {}
public function handle(): void
{
$stats = $this->computeStats();
$this->cache->put('user-stats', $stats, 3600);
}
private function computeStats(): array
{
return [];
}
}Now dependencies are explicit. Someone scanning the constructor immediately knows this job uses caching. Testing is cleaner:
use PHPUnit\Framework\TestCase;
use Mockery\MockInterface;
public function test_stores_stats_in_cache()
{
$cache = \Mockery::mock(CacheRepository::class);
$cache->shouldReceive('put')
->once()
->with('user-stats', \Mockery::any(), 3600);
$job = new UpdateUserStats($cache);
$job->handle();
}You pass the mock directly. No static mocking, no guessing what's being depended on. The code is self-documenting: read the constructor, know the dependencies.
There's a place for facades—configuration code, one-off scripts, prototyping. But in core business logic, in classes that might be tested or reused, injection makes you more productive.
Common mistakes
- Binding too late. If you bind something after a request starts, it's already been resolved. Register everything in service providers, especially in
register()notboot(). Theregisterphase runs first and is for bindings;bootis for setup that depends on other services being registered. - Circular dependencies. If Service A needs B and B needs A, the container will hit infinite recursion. Restructure to break the cycle, or use lazy-loading with closures.
- Resolving in the wrong place. Don't call
app(SomeService::class)inside a class constructor or method if you can inject it. You're hiding the dependency and making it harder to test. - Forgetting to type-hint. If you write
public function __construct($service)without a type, Laravel won't auto-resolve it. Always type your dependencies. - Mixing singletons and mutable state carelessly. If your singleton holds a collection that's modified, those changes persist across requests in some contexts. Clear state intentionally if needed.
Key takeaways
- The service container is a registry and factory—it stores blueprints and builds objects on demand.
- Type-hint dependencies in constructors and let Laravel auto-resolve them; you rarely need to call
newexplicitly. - Bind interfaces to implementations in service providers for maximum flexibility; swap implementations without touching consumers.
- Use
singleton()for objects that should exist once per request or globally, like configuration managers or database pools. - Prefer constructor injection over facades in production code—it makes dependencies visible, code testable, and intent clear.
- Register bindings in the
register()method of service providers, and useboot()only when you need other services to already exist.






Comments
Loading comments…