Capstone: Patterns in Concert
Laravel: Service providers, event system, middleware pipeline
Capstone: Patterns in Concert
Why This Module Exists
Throughout this course, you have studied each design pattern in isolation. You built a factory that creates payment gateways, a strategy that swaps fee algorithms, a decorator that wraps processors with logging, and an observer that fires events to listeners. Each pattern solved a specific problem cleanly.
But real applications never use just one pattern. A production kiosk payment system — the kind deployed in malls, convenience stores, and payment centers across the Philippines — combines multiple patterns working together in a single request lifecycle. The factory creates the right gateway. The strategy calculates the right fee. The decorator adds logging transparently. The observer notifies downstream systems after the transaction completes.
This capstone module asks you to wire all four patterns together into a cohesive KioskPaymentSystem class. The goal is not to learn new concepts but to prove that you can compose the patterns you already know into a working architecture.
The KioskPaymentSystem Architecture
Here is the system you will build:
┌─────────────────────────────┐
│ KioskPaymentSystem │
│ │
"gcash", 150.00 │ 1. Factory creates gateway │
─────────────────>│ 2. Decorator wraps gateway │
│ 3. Strategy calculates fee │
│ 4. Decorated gateway charges │
│ 5. Observer dispatches event │
│ │
└──────────┬──────────────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
ReceiptPrinter AuditLogger Analytics
(listener) (listener) (listener)
Pattern Roles
Factory — PaymentGatewayFactory::create(string $type) receives a type string like "gcash", "maya", or "cash" and returns the correct PaymentGatewayInterface implementation. The KioskPaymentSystem never uses new GCashGateway() directly. It delegates creation to the factory, so adding a new gateway later means changing the factory alone.
Strategy — FeeStrategyInterface defines how transaction fees are calculated. A FlatFeeStrategy always returns a fixed amount (e.g., 15 pesos). A PercentageFeeStrategy returns a percentage of the transaction amount (e.g., 2%). The kiosk operator chooses the strategy at configuration time, and the KioskPaymentSystem uses whichever strategy was injected without knowing the calculation details.
Decorator — LoggingDecorator implements PaymentGatewayInterface and wraps another gateway. Before delegating the charge() call to the inner gateway, it echoes a log line. From the outside, it looks and behaves exactly like a gateway — same interface, same return type. The KioskPaymentSystem does not know it is talking to a decorator rather than a raw gateway.
Observer — TransactionEventDispatcher holds a list of callable listeners. After the payment is processed, the KioskPaymentSystem dispatches an event containing the gateway name, original amount, fee, and total. Every registered listener fires independently. Adding a new listener (SMS notifications, dashboard updates, analytics) requires zero changes to the payment system itself.
Data Flow: Step by Step
Here is exactly what happens when processPayment("gcash", 150.00) is called:
1. Factory: PaymentGatewayFactory::create("gcash")
→ returns new GCashGateway()
2. Decorator: new LoggingDecorator($gcashGateway)
→ wraps the gateway with logging
3. Strategy: $feeStrategy->calculate(150.00)
→ FlatFeeStrategy returns 15.00
→ total = 150.00 + 15.00 = 165.00
4. Charge: $decoratedGateway->charge(165.00)
→ LoggingDecorator echoes "[LOG] Charging 165 via GCash"
→ delegates to GCashGateway::charge(165.00)
→ returns "GCash payment of 165 processed"
5. Observer: $dispatcher->dispatch([
'gateway' => 'GCash',
'amount' => 150.00,
'fee' => 15.00,
'total' => 165.00,
])
→ ReceiptPrinter fires
→ AuditLogger fires
6. Return: "GCash payment of 165 processed"
The Anti-Pattern: The God Class
What does this system look like without patterns? Everything lives in one massive class:
class KioskPaymentProcessor
{
public function process(string $type, float $amount, string $feeType): string
{
// Gateway creation — hardcoded
if ($type === 'gcash') {
$gatewayName = 'GCash';
// ... GCash-specific setup
} elseif ($type === 'maya') {
$gatewayName = 'Maya';
// ... Maya-specific setup
} elseif ($type === 'cash') {
$gatewayName = 'Cash';
// ... Cash-specific setup
} else {
throw new \Exception("Unknown type");
}
// Fee calculation — hardcoded
if ($feeType === 'flat') {
$fee = 15.0;
} elseif ($feeType === 'percentage') {
$fee = $amount * 0.02;
} else {
$fee = 0;
}
// Logging — hardcoded
echo "[LOG] Charging " . ($amount + $fee) . " via {$gatewayName}\n";
// Actual charge — mixed in with everything else
$result = "Payment of " . ($amount + $fee) . " via {$gatewayName}";
// Notifications — hardcoded
echo "RECEIPT: {$gatewayName} — " . ($amount + $fee) . "\n";
echo "AUDIT: {$gatewayName} — " . ($amount + $fee) . "\n";
return $result;
}
}
This class has at least four reasons to change: new gateways, new fee calculations, new logging requirements, and new notification channels. It violates the Single Responsibility Principle, the Open/Closed Principle, and the Dependency Inversion Principle all at once.
Every new requirement means editing this one class. Every edit risks breaking something unrelated. The fee calculation logic cannot be unit tested without also testing gateway creation. The logging cannot be toggled off for testing. The notification list cannot be modified at runtime.
When you separate these concerns into Factory, Strategy, Decorator, and Observer, each piece becomes independently testable, independently replaceable, and independently extendable. That is the power of patterns working in concert.
Pattern Interaction Map
Patterns do not just coexist — they hand off to each other:
| Step | Pattern | Input | Output |
|------|---------|-------|--------|
| 1 | Factory | "gcash" (string) | GCashGateway (object) |
| 2 | Decorator | GCashGateway (object) | LoggingDecorator wrapping it |
| 3 | Strategy | 150.00 (float) | 15.00 (fee) |
| 4 | Decorator → Gateway | 165.00 (total) | "GCash payment of 165 processed" |
| 5 | Observer | ['gateway', 'amount', 'fee', 'total'] | Listener side effects |
Notice how the output of one pattern becomes the input of the next. The Factory's output (a gateway object) feeds into the Decorator's constructor. The Strategy's output (a fee) feeds into the charge amount. The charge result feeds into the Observer's event data. This is composition at its finest.
What You Learned
- Patterns are composable. Each pattern solves one problem. When you combine them, you get a system where each concern is isolated and each component can be tested, replaced, or extended independently.
- The orchestrator stays thin. The
KioskPaymentSystemclass does not contain business logic for any individual concern. It delegates to the factory, strategy, decorator, and observer. If any of those need to change, the orchestrator does not. - New requirements do not break existing code. Need a new gateway? Add a class and update the factory. New fee structure? Implement a new strategy. Need caching on top of logging? Wrap the decorator in another decorator. New notification channel? Register another listener. None of these changes touch the existing, tested code.
- Real production systems look like this. The kiosk payment system you built in this challenge mirrors the architecture of real payment processing applications. Laravel's own service container, middleware pipeline, and event system use these same patterns internally.
Patterns in Concert: How Laravel Combines Them
In a real Laravel application, these four patterns do not live in separate tutorial files — they work together in every request. Here is how you would architect the same kiosk payment system using Laravel's native tools.
Factory: Service Providers and Container Bindings
Laravel's service container is a factory on steroids. Instead of writing your own PaymentGatewayFactory class, you register bindings in a service provider:
// app/Providers/PaymentServiceProvider.php
class PaymentServiceProvider extends ServiceProvider
{
public function register(): void
{
// Contextual binding — the "factory" decides which
// implementation to inject based on configuration
$this->app->bind(PaymentGatewayInterface::class, function ($app) {
$type = config('payment.default_gateway');
return match ($type) {
'gcash' => new GCashGateway(config('payment.gcash')),
'maya' => new MayaGateway(config('payment.maya')),
'cash' => new CashGateway(),
default => throw new \InvalidArgumentException(
"Unknown gateway: {$type}"
),
};
});
}
}
When a controller or service type-hints PaymentGatewayInterface, Laravel's container resolves it automatically — the calling code never knows which concrete class it received. This is the Factory pattern built into the framework.
For cases where you need to create different gateways at runtime (not just at boot time), you can register a factory as a service:
$this->app->singleton(PaymentGatewayFactory::class, function () {
return new PaymentGatewayFactory();
});
Then inject the factory wherever you need runtime creation:
class KioskPaymentService
{
public function __construct(
private PaymentGatewayFactory $gatewayFactory,
) {}
public function process(string $type, float $amount): string
{
$gateway = $this->gatewayFactory->create($type);
return $gateway->charge($amount);
}
}
Strategy: Config-Driven Algorithm Selection
Laravel makes strategy selection trivial through configuration:
// config/payment.php
return [
'fee_strategy' => env('PAYMENT_FEE_STRATEGY', 'flat'),
'fee_strategies' => [
'flat' => \App\Fees\FlatFeeStrategy::class,
'percentage' => \App\Fees\PercentageFeeStrategy::class,
'tiered' => \App\Fees\TieredFeeStrategy::class,
],
];
// In the service provider:
$this->app->bind(FeeStrategyInterface::class, function ($app) {
$strategy = config('payment.fee_strategy');
$class = config("payment.fee_strategies.{$strategy}");
if (!$class || !class_exists($class)) {
throw new \RuntimeException("Unknown fee strategy: {$strategy}");
}
return new $class();
});
Now changing the fee calculation algorithm for the entire application is a one-line .env change:
PAYMENT_FEE_STRATEGY=percentage
No code changes. No deployments. The strategy pattern, combined with Laravel's configuration system, gives you runtime flexibility without touching source code.
Decorator: Middleware as Decoration
Laravel's middleware pipeline is the Decorator pattern applied to HTTP requests. Each middleware wraps the next handler, adding behavior before or after:
// app/Http/Middleware/LogPaymentRequest.php
class LogPaymentRequest
{
public function handle(Request $request, Closure $next): Response
{
// BEFORE — decorator behavior
Log::info('Payment request started', [
'gateway' => $request->input('gateway_type'),
'amount' => $request->input('amount'),
]);
// DELEGATE — pass to the next middleware or controller
$response = $next($request);
// AFTER — more decorator behavior
Log::info('Payment request completed', [
'status' => $response->status(),
]);
return $response;
}
}
Apply it to routes:
Route::post('/kiosk/pay', [KioskController::class, 'pay'])
->middleware(['log.payment', 'throttle:10,1']);
Each middleware is a decorator around the controller action. LogPaymentRequest wraps the controller. ThrottleRequests wraps LogPaymentRequest. The controller does not know it is being decorated, and decorators can be added, removed, or reordered without changing the controller.
You can also apply the Decorator pattern at the service level, not just HTTP. Laravel's container makes this straightforward:
$this->app->extend(PaymentGatewayInterface::class, function ($gateway, $app) {
return new LoggingDecorator($gateway, $app->make(LoggerInterface::class));
});
The extend method wraps an existing binding — exactly what a decorator does.
Observer: Events and Listeners
Laravel's event system is the Observer pattern with syntactic sugar. Define an event, register listeners, and dispatch:
// app/Events/TransactionCompleted.php
class TransactionCompleted
{
public function __construct(
public readonly string $gateway,
public readonly float $amount,
public readonly float $fee,
public readonly float $total,
) {}
}
// app/Listeners/PrintReceipt.php
class PrintReceipt
{
public function handle(TransactionCompleted $event): void
{
// Generate and print receipt
Receipt::create([
'gateway' => $event->gateway,
'total' => $event->total,
])->sendToPrinter();
}
}
// app/Listeners/LogAuditEntry.php
class LogAuditEntry
{
public function handle(TransactionCompleted $event): void
{
Log::channel('audit')->info('Transaction completed', [
'gateway' => $event->gateway,
'amount' => $event->amount,
'fee' => $event->fee,
'total' => $event->total,
]);
}
}
Register them in EventServiceProvider or use Laravel's auto-discovery:
protected $listen = [
TransactionCompleted::class => [
PrintReceipt::class,
LogAuditEntry::class,
SendSmsConfirmation::class,
UpdateDashboard::class,
],
];
Dispatch after payment:
event(new TransactionCompleted(
gateway: 'GCash',
amount: 150.00,
fee: 15.00,
total: 165.00,
));
Adding a new listener (analytics, webhook notification, loyalty points) means creating a new listener class and registering it. The payment service that dispatches the event never changes.
All Four Together: A Real Laravel Payment Service
Here is how the four patterns compose in a production Laravel service:
// app/Services/KioskPaymentService.php
class KioskPaymentService
{
public function __construct(
private PaymentGatewayFactory $gatewayFactory,
private FeeStrategyInterface $feeStrategy,
) {}
public function processPayment(string $gatewayType, float $amount): string
{
// Factory: create the right gateway
$gateway = $this->gatewayFactory->create($gatewayType);
// Decorator: wrapping happens via container's extend() —
// the gateway is already decorated when we receive it
// Strategy: calculate fees
$fee = $this->feeStrategy->calculate($amount);
$total = $amount + $fee;
// Core operation
$result = $gateway->charge($total);
// Observer: fire event — listeners handle the rest
event(new TransactionCompleted(
gateway: $gateway->getName(),
amount: $amount,
fee: $fee,
total: $total,
));
return $result;
}
}
The service is thin. It coordinates four patterns, each handling one concern. The service itself has only one reason to change: if the orchestration flow changes. Gateway details, fee logic, logging behavior, and notification handling are all delegated elsewhere.
This is the same architecture you are building in the capstone challenge — just with Laravel's built-in tools instead of hand-rolled implementations.
This example is pre-filled and runnable. Click "Run" to see the output. Feel free to modify the code and experiment.
Complete the code below to pass all test cases. Use the hints if you get stuck.