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

Asfia AimanAsfia Aiman
14 min read

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 model

  • morphable_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.

0
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