Implementing Polymorphic Relationships in Laravel 12: Building a Universal Payment System


Polymorphic relationships represent one of Laravel's most sophisticated database relationship features, enabling developers to create flexible and maintainable database architectures. This comprehensive guide demonstrates the implementation of polymorphic relationships using Laravel 12 with modern best practices through the development of a universal payment system.
Understanding Polymorphic Relationships
A polymorphic relationship allows a model to belong to more than one other model type through a single association. This design pattern enables a unified approach to handling relationships that would otherwise require multiple separate table structures.
The polymorphic relationship utilizes two key columns:
morphable_type
- stores the fully qualified class name of the related modelmorphable_id
- stores the primary key of the related model instance
Business Case and Application Scenarios
Polymorphic relationships are particularly valuable in scenarios where:
Payment processing systems require unified handling across multiple billable entities
Content management systems need flexible attachment or commenting capabilities
Activity logging systems track actions across diverse model types
Notification systems manage alerts for various entity types
Tagging or categorization systems apply metadata across different content types
Architecture Overview: Universal Payment System
This implementation demonstrates a comprehensive payment system capable of processing transactions for orders, subscriptions, invoices, and bookings through a single, unified architecture.
Database Schema Design
The following migration structure establishes the foundation for our polymorphic payment system:
Orders Table Migration:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('order_number')->unique();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->decimal('subtotal', 10, 2);
$table->decimal('tax_amount', 8, 2)->default(0);
$table->decimal('discount_amount', 8, 2)->default(0);
$table->decimal('total_amount', 10, 2);
$table->enum('status', ['pending', 'processing', 'shipped', 'delivered', 'cancelled'])
->default('pending');
$table->json('shipping_address');
$table->json('billing_address');
$table->timestamp('shipped_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'status']);
$table->index(['status', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
};
Subscriptions Table Migration:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->string('subscription_number')->unique();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('plan_name');
$table->decimal('amount', 8, 2);
$table->enum('billing_cycle', ['monthly', 'quarterly', 'yearly']);
$table->enum('status', ['active', 'cancelled', 'expired', 'suspended'])
->default('active');
$table->date('starts_at');
$table->date('ends_at')->nullable();
$table->date('next_billing_date');
$table->integer('trial_days')->default(0);
$table->timestamp('cancelled_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'status']);
$table->index(['status', 'next_billing_date']);
});
}
public function down(): void
{
Schema::dropIfExists('subscriptions');
}
};
Invoices Table Migration:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->string('invoice_number')->unique();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->decimal('amount', 10, 2);
$table->decimal('tax_amount', 8, 2)->default(0);
$table->decimal('total_amount', 10, 2);
$table->enum('status', ['draft', 'sent', 'paid', 'overdue', 'cancelled'])
->default('draft');
$table->text('description')->nullable();
$table->json('line_items');
$table->date('issue_date');
$table->date('due_date');
$table->timestamp('paid_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'status']);
$table->index(['status', 'due_date']);
});
}
public function down(): void
{
Schema::dropIfExists('invoices');
}
};
Bookings Table Migration:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('bookings', function (Blueprint $table) {
$table->id();
$table->string('booking_reference')->unique();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('service_type');
$table->string('service_name');
$table->decimal('amount', 8, 2);
$table->decimal('deposit_amount', 8, 2)->default(0);
$table->enum('status', ['pending', 'confirmed', 'completed', 'cancelled'])
->default('pending');
$table->datetime('booking_date');
$table->datetime('service_date');
$table->json('booking_details');
$table->timestamp('confirmed_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'status']);
$table->index(['service_type', 'service_date']);
});
}
public function down(): void
{
Schema::dropIfExists('bookings');
}
};
Payment Methods Table Migration:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payment_methods', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->enum('type', ['credit_card', 'debit_card', 'paypal', 'bank_transfer', 'crypto']);
$table->string('provider');
$table->string('provider_id');
$table->string('last_four', 4)->nullable();
$table->string('brand')->nullable();
$table->integer('exp_month')->nullable();
$table->integer('exp_year')->nullable();
$table->boolean('is_default')->default(false);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index(['user_id', 'is_default']);
$table->index(['provider', 'provider_id']);
});
}
public function down(): void
{
Schema::dropIfExists('payment_methods');
}
};
Polymorphic Payments Table Migration:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->string('payment_id')->unique();
$table->string('transaction_id')->nullable();
// Polymorphic relationship columns
$table->morphs('payable');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('payment_method_id')->nullable()->constrained()->onDelete('set null');
$table->decimal('amount', 10, 2);
$table->decimal('fee_amount', 8, 2)->default(0);
$table->decimal('net_amount', 10, 2);
$table->string('currency', 3)->default('USD');
$table->string('gateway');
$table->enum('type', ['payment', 'refund', 'partial_refund', 'chargeback']);
$table->enum('status', [
'pending', 'processing', 'completed', 'failed',
'cancelled', 'refunded', 'disputed'
])->default('pending');
$table->json('gateway_response')->nullable();
$table->string('failure_reason')->nullable();
$table->text('notes')->nullable();
$table->timestamp('processed_at')->nullable();
$table->timestamp('failed_at')->nullable();
$table->timestamps();
// Performance optimization indexes
$table->index(['payable_type', 'payable_id']);
$table->index(['user_id', 'status']);
$table->index(['gateway', 'transaction_id']);
$table->index(['status', 'created_at']);
$table->index('payment_id');
});
}
public function down(): void
{
Schema::dropIfExists('payments');
}
};
Contract Implementation for Type Safety
Implementing a contract ensures consistent behavior across all payable entities:
<?php
namespace App\Contracts;
interface Payable
{
public function getPayableAmount(): float;
public function getPayableDescription(): string;
public function getPayableReference(): string;
public function canBeRefunded(): bool;
public function markAsPaid(): bool;
}
Model Implementation
Order Model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use App\Contracts\Payable;
class Order extends Model implements Payable
{
use HasFactory;
protected $fillable = [
'order_number',
'user_id',
'subtotal',
'tax_amount',
'discount_amount',
'total_amount',
'status',
'shipping_address',
'billing_address',
'shipped_at',
];
protected $casts = [
'subtotal' => 'decimal:2',
'tax_amount' => 'decimal:2',
'discount_amount' => 'decimal:2',
'total_amount' => 'decimal:2',
'shipping_address' => 'array',
'billing_address' => 'array',
'shipped_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function payments(): MorphMany
{
return $this->morphMany(Payment::class, 'payable');
}
// Payable contract implementation
public function getPayableAmount(): float
{
return (float) $this->total_amount;
}
public function getPayableDescription(): string
{
return "Order #{$this->order_number}";
}
public function getPayableReference(): string
{
return $this->order_number;
}
public function canBeRefunded(): bool
{
return in_array($this->status, ['processing', 'shipped']) &&
$this->payments()->where('status', 'completed')->exists();
}
public function markAsPaid(): bool
{
return $this->update(['status' => 'processing']);
}
// Business logic methods
public function getTotalPaid(): float
{
return (float) $this->payments()
->where('status', 'completed')
->where('type', 'payment')
->sum('amount');
}
public function isPaid(): bool
{
return $this->getTotalPaid() >= $this->getPayableAmount();
}
public function getRemainingAmount(): float
{
return max(0, $this->getPayableAmount() - $this->getTotalPaid());
}
}
Subscription Model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use App\Contracts\Payable;
class Subscription extends Model implements Payable
{
use HasFactory;
protected $fillable = [
'subscription_number',
'user_id',
'plan_name',
'amount',
'billing_cycle',
'status',
'starts_at',
'ends_at',
'next_billing_date',
'trial_days',
'cancelled_at',
];
protected $casts = [
'amount' => 'decimal:2',
'starts_at' => 'date',
'ends_at' => 'date',
'next_billing_date' => 'date',
'trial_days' => 'integer',
'cancelled_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function payments(): MorphMany
{
return $this->morphMany(Payment::class, 'payable');
}
// Payable contract implementation
public function getPayableAmount(): float
{
return (float) $this->amount;
}
public function getPayableDescription(): string
{
return "{$this->plan_name} - {$this->billing_cycle} subscription";
}
public function getPayableReference(): string
{
return $this->subscription_number;
}
public function canBeRefunded(): bool
{
$lastPayment = $this->payments()
->where('status', 'completed')
->latest()
->first();
return $lastPayment &&
$lastPayment->created_at->diffInDays() <= 7;
}
public function markAsPaid(): bool
{
$nextBillingDate = match($this->billing_cycle) {
'monthly' => $this->next_billing_date->addMonth(),
'quarterly' => $this->next_billing_date->addMonths(3),
'yearly' => $this->next_billing_date->addYear(),
};
return $this->update([
'next_billing_date' => $nextBillingDate,
'status' => 'active'
]);
}
// Business logic methods
public function isActive(): bool
{
return $this->status === 'active' &&
(!$this->ends_at || $this->ends_at->isFuture());
}
public function isInTrial(): bool
{
return $this->trial_days > 0 &&
$this->starts_at->addDays($this->trial_days)->isFuture();
}
public function getDaysUntilNextBilling(): int
{
return $this->next_billing_date->diffInDays();
}
}
Payment Model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
class Payment extends Model
{
use HasFactory;
protected $fillable = [
'payment_id',
'transaction_id',
'payable_type',
'payable_id',
'user_id',
'payment_method_id',
'amount',
'fee_amount',
'net_amount',
'currency',
'gateway',
'type',
'status',
'gateway_response',
'failure_reason',
'notes',
'processed_at',
'failed_at',
];
protected $casts = [
'amount' => 'decimal:2',
'fee_amount' => 'decimal:2',
'net_amount' => 'decimal:2',
'gateway_response' => 'array',
'processed_at' => 'datetime',
'failed_at' => 'datetime',
];
protected static function boot(): void
{
parent::boot();
static::creating(function ($payment) {
if (!$payment->payment_id) {
$payment->payment_id = 'PAY_' . strtoupper(Str::random(12));
}
if (!$payment->net_amount) {
$payment->net_amount = $payment->amount - $payment->fee_amount;
}
});
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function paymentMethod(): BelongsTo
{
return $this->belongsTo(PaymentMethod::class);
}
public function payable(): MorphTo
{
return $this->morphTo();
}
// Query scopes
public function scopeCompleted(Builder $query): Builder
{
return $query->where('status', 'completed');
}
public function scopeSuccessful(Builder $query): Builder
{
return $query->whereIn('status', ['completed', 'processing']);
}
public function scopeFailed(Builder $query): Builder
{
return $query->whereIn('status', ['failed', 'cancelled']);
}
public function scopeByGateway(Builder $query, string $gateway): Builder
{
return $query->where('gateway', $gateway);
}
public function scopeForPayable(Builder $query, Model $payable): Builder
{
return $query->where('payable_type', get_class($payable))
->where('payable_id', $payable->id);
}
// Business logic methods
public function isSuccessful(): bool
{
return in_array($this->status, ['completed', 'processing']);
}
public function canBeRefunded(): bool
{
return $this->status === 'completed' &&
$this->type === 'payment' &&
$this->payable->canBeRefunded();
}
public function markAsCompleted(): bool
{
$updated = $this->update([
'status' => 'completed',
'processed_at' => now()
]);
if ($updated) {
$this->payable->markAsPaid();
event(new \App\Events\PaymentCompleted($this));
}
return $updated;
}
public function markAsFailed(string $reason = null): bool
{
return $this->update([
'status' => 'failed',
'failure_reason' => $reason,
'failed_at' => now()
]);
}
}
Service Layer Implementation
Following SOLID principles, the service layer encapsulates payment processing logic:
<?php
namespace App\Services;
use App\Models\{Payment, PaymentMethod, User};
use App\Contracts\Payable;
use App\Exceptions\PaymentException;
use Illuminate\Support\Facades\DB;
use App\Events\{PaymentInitiated, PaymentCompleted, PaymentFailed};
class PaymentService
{
public function __construct(
private PaymentGatewayService $gatewayService
) {}
public function processPayment(
Payable $payable,
User $user,
PaymentMethod $paymentMethod,
?float $amount = null,
array $options = []
): Payment {
$amount = $amount ?? $payable->getPayableAmount();
return DB::transaction(function () use ($payable, $user, $paymentMethod, $amount, $options) {
$payment = $this->createPaymentRecord(
$payable,
$user,
$paymentMethod,
$amount,
$options
);
event(new PaymentInitiated($payment));
try {
$gatewayResponse = $this->gatewayService->charge(
$paymentMethod,
$amount,
[
'description' => $payable->getPayableDescription(),
'reference' => $payable->getPayableReference(),
'metadata' => [
'payment_id' => $payment->payment_id,
'payable_type' => get_class($payable),
'payable_id' => $payable->id,
]
]
);
$payment->update([
'transaction_id' => $gatewayResponse['transaction_id'],
'status' => $gatewayResponse['status'],
'gateway_response' => $gatewayResponse,
'processed_at' => $gatewayResponse['status'] === 'completed' ? now() : null,
]);
if ($gatewayResponse['status'] === 'completed') {
$payment->markAsCompleted();
}
return $payment->fresh();
} catch (\Exception $e) {
$payment->markAsFailed($e->getMessage());
event(new PaymentFailed($payment, $e->getMessage()));
throw new PaymentException("Payment processing failed: " . $e->getMessage());
}
});
}
public function refundPayment(Payment $payment, ?float $amount = null): Payment
{
if (!$payment->canBeRefunded()) {
throw new PaymentException('This payment cannot be refunded');
}
$refundAmount = $amount ?? $payment->amount;
return DB::transaction(function () use ($payment, $refundAmount) {
$gatewayResponse = $this->gatewayService->refund(
$payment->transaction_id,
$refundAmount
);
$refund = Payment::create([
'payable_type' => $payment->payable_type,
'payable_id' => $payment->payable_id,
'user_id' => $payment->user_id,
'payment_method_id' => $payment->payment_method_id,
'amount' => -$refundAmount,
'fee_amount' => 0,
'net_amount' => -$refundAmount,
'currency' => $payment->currency,
'gateway' => $payment->gateway,
'type' => $refundAmount < $payment->amount ? 'partial_refund' : 'refund',
'status' => $gatewayResponse['status'],
'transaction_id' => $gatewayResponse['transaction_id'],
'gateway_response' => $gatewayResponse,
'processed_at' => $gatewayResponse['status'] === 'completed' ? now() : null,
]);
return $refund->fresh();
});
}
public function getPaymentsForPayable(Payable $payable): \Illuminate\Database\Eloquent\Collection
{
return $payable->payments()
->with(['user', 'paymentMethod'])
->latest()
->get();
}
private function createPaymentRecord(
Payable $payable,
User $user,
PaymentMethod $paymentMethod,
float $amount,
array $options
): Payment {
$feeAmount = $this->gatewayService->calculateFee($amount, $paymentMethod->provider);
return Payment::create([
'payable_type' => get_class($payable),
'payable_id' => $payable->id,
'user_id' => $user->id,
'payment_method_id' => $paymentMethod->id,
'amount' => $amount,
'fee_amount' => $feeAmount,
'net_amount' => $amount - $feeAmount,
'currency' => $options['currency'] ?? 'USD',
'gateway' => $paymentMethod->provider,
'type' => 'payment',
'status' => 'pending',
'notes' => $options['notes'] ?? null,
]);
}
}
Gateway Service Implementation
The gateway service implements the Strategy pattern for multiple payment processors:
<?php
namespace App\Services;
use App\Models\PaymentMethod;
use App\Services\Gateways\{StripeGateway, PayPalGateway, SquareGateway};
use App\Exceptions\PaymentException;
class PaymentGatewayService
{
private array $gateways;
public function __construct()
{
$this->gateways = [
'stripe' => new StripeGateway(),
'paypal' => new PayPalGateway(),
'square' => new SquareGateway(),
];
}
public function charge(PaymentMethod $paymentMethod, float $amount, array $options = []): array
{
$gateway = $this->getGateway($paymentMethod->provider);
return $gateway->charge($paymentMethod, $amount, $options);
}
public function refund(string $transactionId, float $amount): array
{
$gateway = $this->determineGatewayFromTransactionId($transactionId);
return $gateway->refund($transactionId, $amount);
}
public function calculateFee(float $amount, string $provider): float
{
$gateway = $this->getGateway($provider);
return $gateway->calculateFee($amount);
}
private function getGateway(string $provider)
{
if (!isset($this->gateways[$provider])) {
throw new PaymentException("Unsupported payment gateway: {$provider}");
}
return $this->gateways[$provider];
}
private function determineGatewayFromTransactionId(string $transactionId)
{
if (str_starts_with($transactionId, 'ch_')) {
return $this->gateways['stripe'];
} elseif (str_starts_with($transactionId, 'PAYID-')) {
return $this->gateways['paypal'];
}
throw new PaymentException("Cannot determine gateway from transaction ID");
}
}
Controller Implementation
The controller layer handles HTTP requests and delegates business logic to services:
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaymentRequest;
use App\Services\PaymentService;
use App\Models\{Order, Subscription, Invoice, Booking, Payment, PaymentMethod};
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use App\Exceptions\PaymentException;
class PaymentController extends Controller
{
public function __construct(
private PaymentService $paymentService
) {}
public function processPayment(PaymentRequest $request): JsonResponse
{
try {
$payable = $this->getPayableModel(
$request->payable_type,
$request->payable_id
);
$paymentMethod = PaymentMethod::where('id', $request->payment_method_id)
->where('user_id', $request->user()->id)
->where('is_active', true)
->firstOrFail();
$payment = $this->paymentService->processPayment(
$payable,
$request->user(),
$paymentMethod,
$request->amount,
[
'currency' => $request->currency ?? 'USD',
'notes' => $request->notes,
]
);
return response()->json([
'success' => true,
'payment' => $payment->load(['payable', 'paymentMethod']),
'message' => 'Payment processed successfully'
], 201);
} catch (PaymentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 422);
}
}
public function refundPayment(Request $request, Payment $payment): JsonResponse
{
$this->authorize('refund', $payment);
try {
$refund = $this->paymentService->refundPayment(
$payment,
$request->amount
);
return response()->json([
'success' => true,
'refund' => $refund,
'message' => 'Refund processed successfully'
]);
} catch (PaymentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 422);
}
}
public function getPaymentsForPayable(Request $request): JsonResponse
{
$payable = $this->getPayableModel(
$request->payable_type,
$request->payable_id
);
$this->authorize('view', $payable);
$payments = $this->paymentService->getPaymentsForPayable($payable);
return response()->json([
'payments' => $payments,
'summary' => [
'total_paid' => $payments->where('status', 'completed')->where('type', 'payment')->sum('amount'),
'total_refunded' => abs($payments->where('type', 'refund')->sum('amount')),
'payment_count' => $payments->where('type', 'payment')->count(),
]
]);
}
private function getPayableModel(string $type, int $id)
{
$models = [
'order' => Order::class,
'subscription' => Subscription::class,
'invoice' => Invoice::class,
'booking' => Booking::class,
];
if (!isset($models[$type])) {
throw new \InvalidArgumentException("Invalid payable type: {$type}");
}
return $models[$type]::findOrFail($id);
}
}
Request Validation
Comprehensive validation ensures data integrity and security:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PaymentRequest extends FormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'payable_type' => ['required', 'string', Rule::in(['order', 'subscription', 'invoice', 'booking'])],
'payable_id' => ['required', 'integer', 'exists:' . $this->getTableName() . ',id'],
'payment_method_id' => ['required', 'integer', 'exists:payment_methods,id'],
'amount' => ['nullable', 'numeric', 'min:0.01', 'max:999999.99'],
'currency' => ['nullable', 'string', 'size:3', Rule::in(['USD', 'EUR', 'GBP', 'CAD'])],
'notes' => ['nullable', 'string', 'max:500'],
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
if ($this->payment_method_id) {
$paymentMethod = auth()->user()
->paymentMethods()
->where('id', $this->payment_method_id)
->where('is_active', true)
->first();
if (!$paymentMethod) {
$validator->errors()->add('payment_method_id', 'Invalid payment method selected.');
}
if ($paymentMethod && $paymentMethod->isExpired()) {
$validator->errors()->add('payment_method_id', 'Selected payment method has expired.');
}
}
if ($this->amount && $this->payable_type && $this->payable_id) {
$payable = $this->getPayableInstance();
if ($payable && $this->amount > $payable->getPayableAmount()) {
$validator->errors()->add('amount', 'Amount cannot exceed the total amount due.');
}
}
});
}
private function getTableName(): string
{
$tables = [
'order' => 'orders',
'subscription' => 'subscriptions',
'invoice' => 'invoices',
'booking' => 'bookings',
];
return $tables[$this->payable_type] ?? 'orders';
}
private function getPayableInstance()
{
$models = [
'order' => \App\Models\Order::class,
'subscription' => \App\Models\Subscription::class,
'invoice' => \App\Models\Invoice::class,
'booking' => \App\Models\Booking::class,
];
$modelClass = $models[$this->payable_type] ?? null;
return $modelClass ? $modelClass::find($this->payable_id) : null;
}
}
Performance Optimization Strategies
Database Optimization
Efficient Query Loading:
// Optimize polymorphic relationship queries
$payments = Payment::with(['payable', 'user', 'paymentMethod'])
->completed()
->latest()
->paginate(20);
// Type-specific optimization
$orderPayments = Payment::with(['user', 'paymentMethod'])
->where('payable_type', Order::class)
->whereIn('payable_id', $orderIds)
->completed()
->get();
Strategic Indexing:
// Critical indexes for performance
$table->index(['payable_type', 'payable_id', 'status']);
$table->index(['user_id', 'status', 'created_at']);
$table->index(['gateway', 'transaction_id']);
Security Implementation
Authorization Policies
<?php
namespace App\Policies;
use App\Models\{Payment, User};
use Illuminate\Auth\Access\HandlesAuthorization;
class PaymentPolicy
{
use HandlesAuthorization;
public function view(User $user, Payment $payment): bool
{
return $user->id === $payment->user_id ||
$user->hasRole(['admin', 'finance']);
}
public function refund(User $user, Payment $payment): bool
{
return $user->hasRole(['admin', 'finance']) &&
$payment->canBeRefunded();
}
public function viewSensitiveData(User $user, Payment $payment): bool
{
return $user->hasRole(['admin', 'finance']);
}
}
Testing Implementation
Comprehensive Test Suite
<?php
namespace Tests\Feature;
use App\Models\{User, Order, Subscription, Payment, PaymentMethod};
use App\Services\PaymentService;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PaymentServiceTest extends TestCase
{
use RefreshDatabase;
private PaymentService $paymentService;
protected function setUp(): void
{
parent::setUp();
$this->paymentService = app(PaymentService::class);
}
public function test_processes_payment_for_order(): void
{
$user = User::factory()->create();
$order = Order::factory()->create(['user_id' => $user->id]);
$paymentMethod = PaymentMethod::factory()->create(['user_id' => $user->id]);
$payment = $this->paymentService->processPayment(
$order,
$user,
$paymentMethod
);
$this->assertInstanceOf(Payment::class, $payment);
$this->assertEquals($order->id, $payment->payable_id);
$this->assertEquals(Order::class, $payment->payable_type);
$this->assertEquals($order->getPayableAmount(), $payment->amount);
}
public function test_handles_multiple_payable_types(): void
{
$user = User::factory()->create();
$paymentMethod = PaymentMethod::factory()->create(['user_id' => $user->id]);
$order = Order::factory()->create(['user_id' => $user->id]);
$subscription = Subscription::factory()->create(['user_id' => $user->id]);
$orderPayment = $this->paymentService->processPayment($order, $user, $paymentMethod);
$subscriptionPayment = $this->paymentService->processPayment($subscription, $user, $paymentMethod);
$this->assertEquals(Order::class, $orderPayment->payable_type);
$this->assertEquals(Subscription::class, $subscriptionPayment->payable_type);
}
public function test_processes_refunds_correctly(): void
{
$user = User::factory()->create();
$order = Order::factory()->create(['user_id' => $user->id]);
$paymentMethod = PaymentMethod::factory()->create(['user_id' => $user->id]);
$payment = Payment::factory()->create([
'payable_type' => Order::class,
'payable_id' => $order->id,
'user_id' => $user->id,
'payment_method_id' => $paymentMethod->id,
'status' => 'completed',
'amount' => 100.00,
]);
$refund = $this->paymentService->refundPayment($payment, 50.00);
$this->assertEquals('partial_refund', $refund->type);
$this->assertEquals(-50.00, $refund->amount);
$this->assertEquals($order->id, $refund->payable_id);
}
}
Architectural Benefits and Extensibility
System Advantages
Code Reusability: The polymorphic approach eliminates duplicate payment processing logic across different entity types, resulting in a more maintainable codebase.
Scalability: Adding new payable entities requires minimal code changes. Simply implement the Payable
contract and register the new model type.
Consistency: All payment operations follow identical patterns regardless of the underlying entity type, ensuring predictable behavior across the system.
Performance: Single table storage reduces database complexity while maintaining query efficiency through proper indexing strategies.
Extension Example
Adding support for new payable types is straightforward:
<?php
namespace App\Models;
use App\Contracts\Payable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Course extends Model implements Payable
{
public function payments(): MorphMany
{
return $this->morphMany(Payment::class, 'payable');
}
public function getPayableAmount(): float
{
return (float) $this->price;
}
public function getPayableDescription(): string
{
return "Course enrollment: {$this->title}";
}
public function getPayableReference(): string
{
return $this->course_code;
}
public function canBeRefunded(): bool
{
return $this->start_date->isFuture() &&
$this->created_at->diffInDays() <= 14;
}
public function markAsPaid(): bool
{
return $this->update(['enrollment_status' => 'enrolled']);
}
}
Production Considerations
Monitoring and Logging
Implement comprehensive logging for payment operations:
Log::info('Payment initiated', [
'payment_id' => $payment->payment_id,
'payable_type' => $payment->payable_type,
'payable_id' => $payment->payable_id,
'amount' => $payment->amount,
'gateway' => $payment->gateway,
]);
Event-Driven Architecture
Utilize Laravel events for decoupled system integration:
<?php
namespace App\Events;
use App\Models\Payment;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PaymentCompleted
{
use Dispatchable, SerializesModels;
public function __construct(public Payment $payment) {}
}
Conclusion
Polymorphic relationships in Laravel provide a sophisticated solution for creating flexible, maintainable database architectures. The universal payment system implementation demonstrates how this pattern can significantly reduce code complexity while maintaining type safety and performance.
Key implementation benefits include:
Unified Processing Logic: Single service handles all payable entity types
Type Safety: Contract implementation ensures consistent behavior
Extensibility: New payable types integrate seamlessly
Performance: Optimized database structure with strategic indexing
Maintainability: Centralized business logic reduces maintenance overhead
This architecture pattern is particularly valuable in enterprise applications where consistent behavior across diverse entity types is crucial for system reliability and developer productivity.
The polymorphic approach transforms what would traditionally require multiple specialized systems into a single, cohesive solution that scales efficiently with business requirements while maintaining clean, testable code architecture.
Additional Resources
This implementation provides a production-ready foundation for polymorphic payment processing in Laravel applications. The architecture can be adapted and extended based on specific business requirements while maintaining the core principles of flexibility and maintainability.
Subscribe to my newsletter
Read articles from Asfia Aiman directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Asfia Aiman
Asfia Aiman
Hey Hashnode community! I'm Asfia Aiman, a seasoned web developer with three years of experience. My expertise includes HTML, CSS, JavaScript, jQuery, AJAX for front-end, PHP, Bootstrap, Laravel for back-end, and MySQL for databases. I prioritize client satisfaction, delivering tailor-made solutions with a focus on quality. Currently expanding my skills with Vue.js. Let's connect and explore how I can bring my passion and experience to your projects! Reach out to discuss collaborations or learn more about my skills. Excited to build something amazing together! If you like my blogs, buy me a coffee here https://www.buymeacoffee.com/asfiaaiman