Laravel Service Pattern: Applying SOLID Principles for Scalable Code

Updated
featured-image.png

Laravel is built around the concept of dependency injection and the service container, making it easier to manage dependencies in a clean and maintainable way. One of the best practices to structure your Laravel application is by using the Service Pattern.

Here we’re gonna use a case for handling payments, and as we already know, payment is a critical part of a business logic. Instead of embedding payment logic inside controllers, we can use the Service Pattern with an Interface to make it scalable, testable, and maintainable.

This article will cover:

  1. Why use the Service Pattern?
  2. Implementing the Service Pattern for a PaymentMethodService
  3. Binding the service using bind() vs. singleton()
  4. How this implementation complies with SOLID principles

Why Use the Service Pattern?

When building payment systems, we often need to integrate with third-party providers such as PayPal, Stripe, or Bank Transfer. Managing these integrations directly in the controller can result in messy and difficult to maintain code.

By using the Service Pattern with an interface, we can:

✅ Support multiple payment providers (PayPal, Stripe, Bank Transfer, etc.)
✅ Easily switch or extend payment methods without modifying existing code
✅ Keep payment logic separate from controllers for better maintainability

By using the Service Pattern with an Interface, we make it easy to add new payment providers while keeping the core logic consistent and reusable.

Implementing the Service Pattern for Payments

Step 1: Create a Payment Service Interface

We define a contract that all payment methods must follow:

namespace App\Services\Contracts;

interface PaymentMethodService
{
    public function processPayment(float $amount, array $paymentData): string;
}

Now, any payment method (PayPal, Stripe, Bank Transfer) must implement this interface.

Step 2: Implement Different Payment Methods

PayPal Implementation:

namespace App\Services\Payments;

use App\Services\Contracts\PaymentMethodService;

class PayPalService implements PaymentMethodService
{
    public function processPayment(float $amount, array $paymentData): string
    {
        // Simulate a PayPal payment process
        return "Paid $amount via PayPal with transaction ID: " . uniqid();
    }
}

Now we will use only one implementation, but if you want to use Stripe, just do it as implemented in the Paypal method:

namespace App\Services\Payments;

use App\Services\Contracts\PaymentMethodService;

class StripeService implements PaymentMethodService
{
    public function processPayment(float $amount, array $paymentData): string
    {
        // Simulate a Stripe payment process
        return "Paid $amount via Stripe with transaction ID: " . uniqid();
    }
}

Now, our service follows the contract, using the contract makes it easy to switch payment methods dynamically.

Step 3: Bind the Service in AppServiceProvider

In AppServiceProvider.php, we register our services so Laravel knows which implementation to use:

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Services\Contracts\PaymentMethodService;
use App\Services\Payments\PayPalService;

class AppServiceProvider extends ServiceProvider
{
    public function register()
    {
        // Change to StripeService if needed
        // We only bind the interface and the implementation. The class instance would be different on each instantiation
        $this->app->bind(PaymentMethodService::class, PayPalService::class);
    }
}

We use bind() here because each payment request should have its own instance. If we needed a shared instance (e.g., an API client that persists across requests), we would use singleton().

public function register()
{
    // We bind the interface and the implementation, and we also use the same instance across different parts of the code within a single request
    $this->app->singleton(PaymentMethodService::class, PayPalService::class);
}

By using singleton, if we have two instances of an object, we will get the same object.

$service1 = app(PaymentMethodService::class);
$service2 = app(PaymentMethodService::class);

var_dump($service1 === $service2); // true ✅. Normally it would be false, because they should use different memory addresses.

To pass arguments to the service dynamically, we can use a closure.

public function register()
{
    $this->app->bind(PaymentMethodServiceInterface::class, function ($app) {
        $paymentMethod = config('services.default_payment'); // 'paypal' or 'stripe'

        return match ($paymentMethod) {
            'paypal' => new PayPalService(config('services.paypal.api_key')),
            'stripe' => new StripeService(config('services.stripe.api_key')),
            default => throw new \Exception("Invalid payment method"),
        };
    });
}

Step 4: Inject the Service into the Controller

Now, we inject the PaymentMethodService into our controller, and Laravel will automatically resolve the correct implementation (PayPal or Stripe).

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Services\Contracts\PaymentMethodService;

class PaymentController extends Controller
{
    protected PaymentMethodService $paymentService;

    public function __construct(PaymentMethodService $paymentService)
    {
        $this->paymentService = $paymentService;
    }

    public function pay(Request $request)
    {
        $amount = $request->input('amount');
        $paymentData = $request->input('paymentData');

        $result = $this->paymentService->processPayment($amount, $paymentData);

        return response()->json(['message' => $result]);
    }
}

Now, we can switch from PayPal to Stripe just by changing the binding in AppServiceProvider without modifying the controller! 🎉

bind() vs. singleton() for Services

Creates a new instance every time?
bind(): ✅ Yes
singleton(): ❌ No

Shared across requests?
bind(): ❌ No
singleton(): ✅ Yes

Use case?
bind(): Per-request payment processing
singleton(): Shared payment clients (e.g., API clients)

For processing payments, bind() is preferred to ensure that each payment request gets a fresh instance. For caching API keys or configuration, singleton() would be useful.

How This Complies with SOLID Principles

✅ Single Responsibility Principle (SRP)
PaymentController handles HTTP requests. PayPalService or StripeService handle payment logic.

✅ Open/Closed Principle (OCP)
We can add new payment methods (e.g., Bank Transfer) without modifying existing code.

✅ Liskov Substitution Principle (LSP)
Since the controller depends on an interface, we can swap implementations freely.

✅ Interface Segregation Principle (ISP)
If different payment methods require extra functionalities, we can create separate interfaces (e.g., RefundablePaymentMethod).

✅ Dependency Inversion Principle (DIP)
The controller does not depend on a concrete class (PayPalService), but on an interface (PaymentMethodService), making it loosely coupled.

Conclusion

By using the Service Pattern with Interfaces, we achieve:
✅ Flexibility: We can add more payment methods easily.
✅ Scalability: Business logic is separate from controllers.
✅ SOLID Compliance: Following best practices for maintainability.

Fajarwz's photo Fajar Windhu Zulfikar

I'm a full-stack web developer who loves to share my software engineering journey and build software solutions to help businesses succeed.

Email me
Ads
  • Full-Stack Laravel: Forum Web App (Complete Guide 2024)
  • Flexible and powerful review system for Laravel, let any model review and be reviewed.

Share

Subscribe

Sign up for my email newsletter and never miss a beat in the world of web development. Stay up-to-date on the latest trends, techniques, and tools. Don't miss out on valuable insights. Subscribe now!

Comments

comments powered by Disqus