In this multi-post series we are creating our own framework. We have previously created a front controller, added a router and created two controllers. Now it is time to introduce services to the controllers!
Services are reusable components that provide specific functionality to your application—things like logging, database access, sending emails, or translating text. By organizing these capabilities into services, we can share them across different parts of our application without duplicating code.
The problem with services is that if we hardcode them into our controllers, our code becomes tightly coupled and difficult to test. For example:
class DashboardController {public function index(): string {$logger = new FileLogger('/var/log/app.log');$logger->info('Dashboard accessed');return "Welcome to the Dashboard!";}}
This controller is now married to FileLogger. What if we want to log to a database instead? Or what if we want to disable logging during tests? We'd have to modify the controller code itself.
Dependency Injection (DI) solves this by passing (injecting) dependencies into a class rather than having the class create them. This has several advantages:
Here's the same controller using dependency injection:
class DashboardController {public function index(LoggerInterface $logger): string {$logger->info('Dashboard accessed');return "Welcome to the Dashboard!";}}
Now our controller doesn't care what logger it gets—it just needs something that implements LoggerInterface. This means we can easily switch logger backends without touching our business logic. Much better!
If we want to create and use a dependency Injection container, we need something we can add to the container and then inject into our controller. Let's build on the LoggerInterface example and create a very simple Logger.
We want the logger to implement a standard interface so that can more easily swap it out for another implementation, should the need arise. So, let's use the PSR-3 Logger Interface standard. It comes with a composer package so install the package:
composer require psr/log
Now, implement the logger:
<?phpnamespace Framework;use Psr\Log\LoggerInterface;use Psr\Log\LogLevel;class FileLogger implements LoggerInterface{private ?string $logFile;public function __construct(?string $logFile = null){$this->logFile = $logFile;if ($logFile !== null) {$directory = dirname($logFile);if (!is_dir($directory)) {mkdir($directory, 0755, true);}}}public function emergency($message, array $context = []): void{$this->log(LogLevel::EMERGENCY, $message, $context);}public function alert($message, array $context = []): void{$this->log(LogLevel::ALERT, $message, $context);}public function critical($message, array $context = []): void{$this->log(LogLevel::CRITICAL, $message, $context);}public function error($message, array $context = []): void{$this->log(LogLevel::ERROR, $message, $context);}public function warning($message, array $context = []): void{$this->log(LogLevel::WARNING, $message, $context);}public function notice($message, array $context = []): void{$this->log(LogLevel::NOTICE, $message, $context);}public function info($message, array $context = []): void{$this->log(LogLevel::INFO, $message, $context);}public function debug($message, array $context = []): void{$this->log(LogLevel::DEBUG, $message, $context);}public function log($level, $message, array $context = []): void{$logMessage = sprintf("[%s] %s: %s%s\n",date('Y-m-d H:i:s'),strtoupper($level),$message,empty($context) ? "" : " " . json_encode($context),);if ($this->logFile) {file_put_contents($this->logFile, $logMessage, FILE_APPEND | LOCK_EX);} else {file_put_contents($this->getStreamForLevel($level), $logMessage, FILE_APPEND);}}private function getStreamForLevel(string $level): string{$errorLevels = [LogLevel::EMERGENCY,LogLevel::ALERT,LogLevel::CRITICAL,LogLevel::ERROR,LogLevel::WARNING,];return in_array($level, $errorLevels) ? 'php://stderr' : 'php://stdout';}}
To make dependency injection work, we need a container that knows how to create objects and wire up their dependencies. The PHP-FIG (Framework Interoperability Group) has standardized this with PSR-11.
PSR-11 defines a simple container interface with just two methods:
interface ContainerInterface {public function get(string $id);public function has(string $id): bool;}
You can install the necessary vendor package using composer:
composer require psr/container
Let's implement a basic container for our framework:
<?php // src/Container.phpnamespace Framework;use Psr\Container\ContainerInterface;use Psr\Container\NotFoundExceptionInterface;class Container implements ContainerInterface{private array $services = [];public function set(string $id, callable $factory): void{$this->services[$id] = $factory;}public function get(string $id): mixed{if (!$this->has($id)) {throw new class("Service '$id' not found") extends \Exception implements NotFoundExceptionInterface {};}return $this->services[$id]($this);}public function has(string $id): bool{return isset($this->services[$id]);}}
This container stores factory functions that create services. When you call get(), it creates the service and returns it.
Note on performance: In a production application, you should probably store the created service instances, so that they do not need to be created every time you need one. That will also enable you to carry state in the service.
Now we can create our container and add the logger to it! Add this to src/Application.php:
use Psr\Log\LoggerInterface;// in public function run(): string {$container = new Container();$container->set(LoggerInterface::class, fn() => new FileLogger());
What we did here was creating the container, then bind the PSR interface class-string to a function that creates a new logger.
Having a container is great, but the services are still not injected automatically. So our next step will be to build an Injector that automates this using PHP's Reflection API.
The Injector will analyze a class's constructor to see what dependencies it needs, fetch those dependencies from the container, and then create the instance with all dependencies wired up.
Here's the implementation:
<?php // src/Injector.phpnamespace Framework;use Closure;use Psr\Container\ContainerInterface;use ReflectionFunction;use ReflectionNamedType;readonly class Injector{public function __construct(private ContainerInterface $container){}public function call(Closure $closure, array $extraParams = []): mixed{$reflection = new ReflectionFunction($closure);return $reflection->invokeArgs($this->getDependencies($reflection->getParameters(),$extraParams,));}private function getDependencies(array $parameters, array $extraParams): array{$dependencies = [];foreach ($parameters as $parameter) {if (!$parameter->getType() instanceof ReflectionNamedType) {$dependencies[] = $extraParams[$parameter->getName()] ?? null;continue;}if ($parameter->getType()->isBuiltin()) {$dependencies[] = $extraParams[$parameter->getName()] ?? null;continue;}$dependencies[] = $this->container->get($parameter->getType()->getName());}return $dependencies;}}
Our injector is pretty minimal and only performs method/function parameter injection. We will extend the class with other injection methods as needs arise: We will probably want constructor injection at some point as well.
In the foreach loop, we loop through all parameters. If the parameter does not have a type, or if it's a built-in type, we get the value by name from the passed $extraParams array. Otherwise, we attempt to get the dependency from our container.
Now let's see it in action! Update src/Application.php once more to use the injector when calling controllers. Then, add the LoggerInterface to your controller, log something, and check the result!
// in src/Application.phpreturn new Injector($container)->call($callable); // Was: return $callable();
<?php // src/DashboardController.phpuse Psr\Log\LoggerInterface;// ...class DashboardController {public function index(LoggerInterface $logger) {$logger->info("Cool!", ["file"=>__FILE__]);return "Welcome to the Dashboard!";}}
Okay, start your PHP server and check the results. If you go to the homepage at /, you should be greeted with the 'Welcome to the Dashboard' string. And the 'Cool!' message with some context should be output to your server logs.
Now your controllers can declare whatever dependencies they need, and the framework will automatically provide them.
As you can see there are quite a bit of moving parts in even a basic dependency injection system. But, as you will see in the next episodes in the series, it does allow you to centrally initialize and configure your services, and then use them everywhere. It will also allow you to easily replace the separate components of your application with alternatives.
As an alternative to the Dependency Injection pattern, you could also use the Service Locator pattern, which is often simpler to implement (no injector) and might suit your needs.
We now have a working dependency injection system! But we're still returning plain strings from our controllers. In the next episode, we'll tackle the M in MVC—setting up the Model layer with database access using Doctrine ORM. We'll also configure our services to read from environment variables, keeping our configuration flexible and secure.
If you want to see the complete code, check out the repository at this commit.