Dependency Injection in Laravel Controllers
Tuesday, December 10, 2024
// 3 min read
Table of Contents
Dependency Injection (DI) might sound a bit intimidating, but it is a fairly simple technique in programming.
What is Dependency Injection?
Imagine, that you're sitting at home, and you're starving, so you decide to order a pizza. You pull out your phone and call a pizza delivery service. The delivery service does everything for you:
contact the restaurant to make the pizza
contact the delivery guy to bring the pizza to your house.
You do not need to know how the pizza is made or who the delivery guy is. You only care about eating the pizza when it arrives.
What does this have to do with Dependency Injection?
You (the Controller): Want to do something, like order pizza
Pizza Delivery Service (Injected Dependency): This is the service that handles the heavy lifting for you (making the pizza and delivering it).
The Restaurant and Delivery Guy (Other Dependencies): They’re part of the process, but you don’t deal with them directly.
Instead of making the pizza by yourself, you simply call (inject) the service that knows how to do all of these. And if you want donuts instead of pizza, you just inject the Donut Delivery service instead of the Pizza Delivery service. The Controller doesn't care about the details, just wants the job done.
So the main purpose of DI is "inject" the things a class needs (its dependencies) from the outside, instead of letting the class create or manage them itself.
DI in Laravel Controller
Let's see a real-life example where we handle payments in a subscription-based SaaS application. We'll inject a PaymentService
to process payments.
Here is the PaymentService
which uses Stripe integration:
<?php
namespace App\Services;
use App\Models\Plan;
use App\Models\User;
class PaymentService
{
public function processSubscription(User $user, Plan $plan): void
{
if (! $user->stripe_id) {
$this->createStripeCustomer($user);
$user->refresh();
}
\Stripe\Subscription::create([
'customer' => $user->stripe_id,
'items' => [['plan' => $plan->stripe_id]],
'expand' => ['latest_invoice.payment_intent'],
]);
$user->update(['subscription_status' => 'active']);
}
/**
* Create a new customer in Stripe if doesn't exist.
*/
public function createStripeCustomer(User $user): int
{
$customer = \Stripe\Customer::create([
'description' => 'customer for ' . $user->email,
'email' => $user->email,
'payment_method' => 'pm_card_visa',
]);
$user->update(['stripe_id' => $customer->id]);
return $customer->id;
}
}
And then you can inject this service into your controller:
<?php
namespace App\Http\Controllers;
use Exception;
use App\Models\Plan;
use Illuminate\Http\Request;
use App\Services\PaymentService;
class SubscriptionController extends Controller
{
public function __construct(protected PaymentService $paymentService) {}
public function subscribe(Request $request, Plan $plan)
{
$user = $request->user();
try {
// Use the PaymentService to process the payment
$this->paymentService->processSubscription($user, $plan);
return redirect('/')
->with('success', 'Subscription activated successfully!');
} catch (Exception $e) {
return redirect('/')
->with('error', 'Subscription failed: ' . $e->getMessage());
}
}
}
Later in case you need to use a different Payment processing service, for example, PayPal instead of Stripe, you only need to make the changes in the PaymentService
. You do not need to touch your controller.
Type of Dependencies
Besides of Services you can inject another type of dependencies. Here are some examples.
Repositories
Abstract database queries, providing cleaner, reusable code for interacting with models.
class UserRepository
{
public function findByEmail(string $email)
{
return User::where('email', $email)->first();
}
}
class UserController
{
public function __construct(protected UserRepository $userRepository) {}
public function getUserByEmail($email)
{
return $this->userRepository->findByEmail($email);
}
}
Request Classes
Form request classes validate input and can be injected directly into methods.
public function store(Request $request)
{
$request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'is_published' => 'boolean',
]);
Post::create($request->validated());
return redirect()->route('posts.index');
}
Services from Laravel Container
Laravel's service container resolves dependencies like cache, logging, or queuing systems.
<?php
namespace App\Http\Controllers;
use Psr\Log\LoggerInterface;
class LogController extends Controller
{
public function __construct(protected LoggerInterface $logger) {}
public function logMessage(string $message)
{
$this->logger->info($message);
}
}
Jobs
Jobs handle background tasks and are injected when dispatched.
class WelcomeEmailController
{
public function sendWelcomeEmail(User $user)
{
WelcomeEmailJob::dispatch($user);
}
}
Advantages of DI in Controllers
Code Reusability
You can share services and logic across multiple controllers.
Clean Code
Controllers can focus only on handling the request, while the injected dependencies handle the logic.
Testability
You can inject mock dependencies for unit testing. This means you can test your controller independently of the injected dependencies. For instance, to test our example of SubscriptionController
, you can mock the PaymentService
in your test.
<?php
namespace Tests\Feature;
use Mockery;
use Tests\TestCase;
use App\Models\Plan;
use App\Models\User;
use Mockery\MockInterface;
use App\Services\PaymentService;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
class SubscriptionControllerTest extends TestCase
{
use RefreshDatabase, WithFaker;
public function test_subscribe_success()
{
// Create a user and a plan
$user = User::factory()->create();
$plan = Plan::factory()->create();
$this->instance(
PaymentService::class,
Mockery::mock(PaymentService::class, function (MockInterface $mock) {
$mock->shouldReceive('processSubscription')->once();
})
);
// Act as the user and make a post request to subscribe
$response = $this->actingAs($user)->post(route('subscribe', ['plan' => $plan->id]));
// Assert the response
$response->assertRedirect('/')
->assertSessionHas('success', 'Subscription activated successfully!');
}
}
Advanced Usage with Binding Dependencies
Let's go back to our Subscription example. What happens if you need to switch from Stripe to PayPal later?
You can just change your code inside PaymentService
, but that is not maintainable because later you might want to change back to Stripe again or add a different provider. Instead, you can make the code more maintainable and adaptable for switching from one payment service to another by applying the Strategy Pattern. This involves abstracting the payment logic into an interface, allowing you to select implementations easily.
Here’s how you can improve your code:
Create a common interface that all payment gateways (e.g., Stripe, PayPal) must implement:
<?php namespace App\Contracts; use App\Models\User; use App\Models\Plan; interface PaymentGatewayInterface { public function createSubscription(User $user, Plan $plan): void; }
Move the Stripe-specific logic into its own class:
<?php namespace App\Services; use App\Contracts\PaymentGatewayInterface; use App\Models\Plan; use App\Models\User; class StripePaymentService implements PaymentGatewayInterface { public function createSubscription(User $user, Plan $plan): void { if (! $user->stripe_id) { $this->createCustomer($user); $user->refresh(); } \Stripe\Subscription::create([ 'customer' => $user->stripe_id, 'items' => [['plan' => $plan->stripe_id]], 'expand' => ['latest_invoice.payment_intent'], ]); $user->update(['subscription_status' => 'active']); } protected function createCustomer(User $user): int { $customer = \Stripe\Customer::create([ 'description' => 'customer for ' . $user->email, 'email' => $user->email, 'payment_method' => 'pm_card_visa', ]); $user->update(['stripe_id' => $customer->id]); return $customer->id; } }
Create a similar implementation for PayPal:
<?php namespace App\Services; use App\Models\Plan; use App\Models\User; use Srmklive\PayPal\Services\PayPal; use App\Contracts\PaymentGatewayInterface; class PayPalPaymentService implements PaymentGatewayInterface { protected $provider; public function __construct() { $this->provider = new PayPal; $this->provider->setApiCredentials(config('paypal')); } public function createSubscription(User $user, Plan $plan): void { $data = [ 'plan_id' => $plan->paypal_id, 'quantity' => 1, 'shipping_amount' => [ 'currency_code' => 'EUR', 'value' => '0.00', ], 'subscriber' => [ 'name' => [ 'given_name' => $user->first_name, 'surname' => $user->last_name, ], 'email_address' => $user->email, ], 'application_context' => [ 'brand_name' => config('app.name'), 'locale' => 'en-US', 'shipping_preference' => 'SET_PROVIDED_ADDRESS', 'user_action' => 'SUBSCRIBE_NOW', 'payment_method' => [ 'payer_selected' => 'PAYPAL', 'payee_preferred' => 'IMMEDIATE_PAYMENT_REQUIRED', ], 'return_url' => route('paypal.return'), 'cancel_url' => route('paypal.cancel'), ], ]; $this->provider->createSubscription($data); } }
Inject the payment gateway dynamically so it can use Stripe, PayPal, or another service:
<?php namespace App\Services; use App\Contracts\PaymentGatewayInterface; use App\Models\Plan; use App\Models\User; class PaymentService { protected PaymentGatewayInterface $paymentGateway; public function __construct(PaymentGatewayInterface $paymentGateway) { $this->paymentGateway = $paymentGateway; } public function processSubscription(User $user, Plan $plan): void { $this->paymentGateway->createSubscription($user, $plan); } }
Use Laravel's service container to bind the interface to a specific implementation:
<?php namespace App\Providers; use App\Services\StripePaymentService; use Illuminate\Support\ServiceProvider; use App\Contracts\PaymentGatewayInterface; class AppServiceProvider extends ServiceProvider { /** * Register any application services. */ public function register(): void { $this->app->bind(PaymentGatewayInterface::class, StripePaymentService::class); } /** * Bootstrap any application services. */ public function boot(): void { // } }
If you want to switch to PayPal, you can simply change the binding:
$this->app->bind(PaymentGatewayInterface::class, PayPalPaymentService::class);
No changes are required in the controller, since the PaymentService
now dynamically uses the selected payment gateway.
I hope I helped you to understand why Dependency Injection is useful. However, be wary that injecting too many dependencies into a single class makes it overly complex and hard to test or maintain, so use it wisely. Happy coding!
If you want to support my work, you can donate using the button below. Thank you!