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:
- Why use the Service Pattern?
- Implementing the Service Pattern for a PaymentMethodService
- Binding the service using bind() vs. singleton()
- 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()
: ✅ Yessingleton()
: ❌ No
Shared across requests?bind()
: ❌ Nosingleton()
: ✅ Yes
Use case?bind()
: Per-request payment processingsingleton()
: 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.