Sitemap

Dependency Injection in PHP: Laravel’s Magic vs. Symfony’s Explicitness

7 min readApr 19, 2026

--

A practical comparison of dependency management philosophies using a third-party weather API integration.

Press enter or click to view image in full size

In the programming world, we rarely argue about whether to use good design patterns. The real battles are fought over how to implement them.

One of the most important foundations of modern applications is Dependency Injection (DI). Imagine building a car. Instead of welding the engine permanently to the chassis (which would make it a nightmare to replace if it breaks), you design the engine bay so that a fully assembled engine can simply be “dropped in.” In code, this means passing fully constructed objects into classes, rather than allowing classes to create them internally.

In the PHP ecosystem, we have two giants that approach this topic entirely differently: Laravel and Symfony. The former focuses on Developer Experience (DX) and a bit of “magic,” while the latter prioritizes predictability, rigor, and absolute explicitness.

Who is right? Let’s find out using a practical example.

💡 Code for this article: You don’t have to blindly copy the code! I have prepared a complete GitHub repository with two ready-to-run applications (Laravel 12 and Symfony 7+). You’ll find the link and instructions at the end of this post. The path comments above the code snippets will help you navigate the project structure!

Our Proving Ground: A Third-Party Weather API

Let’s build something we all have to write at some point: an external API integration. Our application needs a weather service. To truly understand the mechanics of both frameworks, we will first try to integrate an external (Third-Party) library, and then we’ll see how these mechanisms have evolved recently.

We start with an interface (a good practice to avoid tightly coupling our code to a single implementation):

PHP

<?php
// 📍 File: laravel-app/app/Services/WeatherServiceInterface.php
// (and analogously: symfony-app/src/Service/WeatherServiceInterface.php)

namespace App\Services;

interface WeatherServiceInterface
{
public function getCurrentTemperature(string $city): float;
}

Now, our concrete implementation. Notice that the constructor explicitly declares what it needs to survive: an external HTTP client (we’ll use the popular GuzzleHttp\Client) and an API key (for authorization).

PHP

<?php
// 📍 File: laravel-app/app/Services/OpenWeatherService.php
// (Classic approach injecting Guzzle)

declare(strict_types=1);

namespace App\Services;

use App\Services\WeatherServiceInterface;
use GuzzleHttp\Client;

class OpenWeatherService implements WeatherServiceInterface
{
public function __construct(
private Client $httpClient,
private string $apiKey
) {}

public function getCurrentTemperature(string $city): float
{
// Fetch current weather using WeatherAPI.com
$response = $this->httpClient->get("https://api.weatherapi.com/v1/current.json", [
'query' => [
'key' => $this->apiKey,
'q' => $city,
'aqi' => 'no'
]
]);

$data = json_decode($response->getBody()->getContents(), true);

return (float) $data['current']['temp_c'];
}
}

The API key is a secret, of course, so we keep it in our environment file:

Code snippet

# 📍 File: laravel-app/.env (and symfony-app/.env)
WEATHER_API_KEY=api_key_weatherapi_com

How do we make our framework automatically “understand” that when we ask for WeatherServiceInterface in a controller, it should hand us OpenWeatherService with an injected Guzzle client and the key from .env? This is where the architectural duel begins.

Corner 1: Laravel and its “Magic” Service Container

Laravel loves to make life easier. Its Service Container relies heavily on a mechanism called the Reflection API. This means the framework can scan your classes on the fly, check what’s missing in the constructor, and inject it automatically.

To configure our external service, we’ll use the AppServiceProvider.

PHP

<?php
// 📍 File: laravel-app/app/Providers/AppServiceProvider.php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Services\WeatherServiceInterface;
use App\Services\OpenWeatherService;
use GuzzleHttp\Client;

class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// We tell the container: "When someone asks for the interface in a controller,
// build and give them this specific implementation."
$this->app->bind(WeatherServiceInterface::class, function ($app) {
return new OpenWeatherService(
new Client(), // We instantiate the HTTP client
config('services.weather.key') // We fetch the key from the config (read from .env)
);
});

// $this->app->bind(WeatherServiceInterface::class, function ($app) {
// return new ModernWeatherService();
// });
}
}

configuration system. We do this by adding a new array to the `config/services.php` file:

PHP

<?php
// 📍 File: laravel-app/config/services.php

return [
// ... other services like mailgun, postmark, etc. ...

'weather' => [
'key' => env('WEATHER_API_KEY'),
],
];
Press enter or click to view image in full size

What just happened? A single Closure was all it took. Now, in the controller, you just write: public function show(WeatherServiceInterface $weather), and everything just works! You don't need to configure anything else. This flexibility is perfect for integrating unconventional libraries and writing conditional logic directly in PHP.

Corner 2: Symfony, Autowiring, and Absolute Explicitness

Symfony is an engineering titan. Historically, there was no room for guesswork here. Although Symfony has a phenomenal Autowiring mechanism (guessing object classes based on types), for scalar variables (like our API key as a string), it didn't allow for magic hidden behind a helper function. It forced you to declare it explicitly in a configuration file.

Let’s look at services.yaml:

YAML

# 📍 File: symfony-app/config/services.yaml

parameters:
# 1. Load the environment variable into a parameter
weather_api_key: '%env(WEATHER_API_KEY)%'

services:
# 2. Default configuration for all services in this file
_defaults:
autowire: true # Automatically inject object dependencies (e.g., Client)
autoconfigure: true # Automatically register Event Subscribers, etc.

# 2.5 Automatically registers all classes in src/ as services
App\:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'

# Make Guzzle available for autowiring
GuzzleHttp\Client: ~

# 3. Explicitly declare how our specific service should be constructed
App\Services\OpenWeatherService:
arguments:
$apiKey: '%weather_api_key%'

# 4. Tell the container: use this class as the default for the interface
App\Services\WeatherServiceInterface: '@App\Services\OpenWeatherService'
# App\Services\WeatherServiceInterface: '@App\Services\ModernWeatherService'
Press enter or click to view image in full size

What just happened? Symfony will inject GuzzleHttp\Client on its own thanks to autowire being enabled. However, for $apiKey, we must explicitly state (arguments:) where the container should get the value for this specific argument. This file is the "Holy Grail" of your architecture. You open it, and you instantly have a precise blueprint of your entire application.

How Do We Do It Today? The Evolution of Frameworks (2025/2026 Standards)

Technology doesn’t stand still. Both frameworks have drastically improved Developer Experience (DX). Our Guzzle example perfectly illustrates “pure” dependency injection, but how would we write this code utilizing the latest features?

Laravel’s Magic Reaches a New Level (Facades)

In modern Laravel, we often forgo injecting native tools via the constructor altogether. Instead of binding Guzzle, we simply use the Http Facade.

PHP

<?php
// 📍 File: laravel-app/app/Services/ModernWeatherService.php

declare(strict_types=1);

namespace App\Services;

use Illuminate\Support\Facades\Http;

class ModernWeatherService implements WeatherServiceInterface
{
public function getCurrentTemperature(string $city): float
{
// Zero constructor DI! Using Laravel's Http Facade
$response = Http::get("https://api.weatherapi.com/v1/current.json", [
'key' => config('services.weather.key'),
'q' => $city
]);

return (float) $response->json('current.temp_c');
}
}

Takeaway: Laravel prioritizes maximum delivery speed (Time-To-Market). Today, Service Providers are mainly used for complex domain logic or specific third-party libraries.

Symfony Discovers PHP Attributes

Conversely, Symfony revolutionized autowiring by moving it from the YAML file directly into the class code using Attributes (PHP 8+). We don’t lose explicitness, but we drastically reduce boilerplate.

The services.yaml file becomes almost empty, and our class looks like this:

PHP

<?php
// 📍 File: symfony-app/src/Service/ModernWeatherService.php

declare(strict_types=1);

namespace App\Service;

use App\Services\WeatherServiceInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class ModernWeatherService implements WeatherServiceInterface
{
public function __construct(
private HttpClientInterface $httpClient,

// Explicitly wire the API key directly in the class
#[Autowire(env: 'WEATHER_API_KEY')]
private string $apiKey
) {}

public function getCurrentTemperature(string $city): float
{
// Fetch data using Symfony's native HTTP client and WeatherAPI.com
$response = $this->httpClient->request('GET', 'https://api.weatherapi.com/v1/current.json', [
'query' => [
'key' => $this->apiKey,
'q' => $city
]
]);

$data = $response->toArray();

return (float) $data['current']['temp_c'];
}
}

Takeaway: The explicitness remains, but it sits right next to the logic. It’s a brilliant compromise between coding convenience and ironclad engineering principles.

Magic vs. Explicitness: What to Choose? (Trade-off Analysis)

There are no losers here, only different priorities.

Choose Laravel’s approach if:

  • You value work pace, outstanding Developer Experience, and clean code without massive YAML configuration files.
  • You are building an MVP or an application where Time-To-Market is the most critical metric.
  • You enjoy flexibility in your code (Facades, global helpers).

Choose Symfony’s approach if:

  • You are building a massive, long-lasting Enterprise application.
  • You demand absolute predictability — you want dependency configuration errors to trigger a container compilation error (Symfony Compile Pass) rather than failing unexpectedly in a production runtime environment.
  • You want your architectural documentation to emerge directly from interfaces, attributes, and configurations.

🛠️ Check it out yourself! (GitHub Repository)

Theory is one thing, but code is best analyzed in its natural habitat. I’ve prepared a “Monorepo” style repository where you’ll find two fully functional, isolated applications.

👉 [Link to GitHub repository: php-di-laravel-vs-symfony] (https://github.com/lukaszzychal/articles-code/tree/main/05-php-di-laravel-vs-symfony)

Inside, you will find:

  • laravel-app/ – A clean Laravel 12 installation, where you can toggle between the classic and modern service in the AppServiceProvider.
  • symfony-app/ – A clean Symfony 7+ installation, with a prepared services.yaml file and classes based on #[Autowire] Attributes.

Clone the repo, paste your API key into the .env files, and see how both approaches behave in practice!

Conclusion

Dependency Injection is a powerful tool. Laravel seduces developers with its “magic” and speed, while Symfony teaches rigor and engineering precision. Regardless of your choice, understanding how a framework handles your dependencies under the hood simply makes you a better engineer.

Which camp are you in? Do you prefer the speed of Laravel Facades or the peace of mind provided by Symfony’s Attributes and rigor? Let me know in the comments!

--

--

Lukasz Zychal
Lukasz Zychal

Written by Lukasz Zychal

PHP developer and systems architect. Architecture, DevOps, AI. Simplifying complexity in code and thinking.