Backend Development

Implementing a Pricing System in Your Laravel Project

Implementing a Pricing System in Your Laravel Project

Pricing systems are a common requirement for SaaS platforms and subscription-based products. In this tutorial we will implement a dynamic pricing system in your laravel project.

 

 

Many today web applications especially Sass-based applications have a pricing feature so that users can subscribe to in order to use the website features. To do so websites usually have a pricing page that contain a table list of packages or plans starting from the free plan to paid plans.

In this article we’ll walk through how to build a dynamic pricing system using Laravel, complete with database structure, models, controllers, and a front-end pricing table.

 

Step 1: Database Design

We’ll use three main tables:

  • plans: stores plan details (e.g., name, price, billing cycle)
  • features: lists all possible features (e.g., “Storage”, “Support”, etc)
  • plan_features: defines which features belong to which plan.
┌──────────────────────┐          ┌──────────────────────┐
│       plans          │          │      features        │
├──────────────────────┤          ├──────────────────────┤
│ id (PK)              │          │ id (PK)              │
│ name                 │          │ name                 │
│ slug                 │          │ created_at           │
│ price                │          │ updated_at           │
│ billing_cycle        │          └──────────────────────┘
│ description          │
│ created_at           │
│ updated_at           │
└─────────┬────────────┘
          │ 1        N
          │
          │
┌─────────▼────────────┐
│    plan_features      │
├───────────────────────┤
│ id (PK)               │
│ plan_id (FK → plans)  │
│ feature_id (FK → features) │
│ value (feature detail)│
│ created_at            │
│ updated_at            │
└───────────────────────┘

 

Step 2: Models and Migrations

Create the table migrations and models with artisan:

php artisan make:model Plan -m
php artisan make:model Feature -m
php artisan make:migration create_plan_features_table

Run these commands in order, it will create the models and migrations for the previous tables (plans, features and plan_features). 

Open each migration and populate with the below code:

__create_plans_table.php

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('plans', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->decimal('price', 10, 2)->default(0);
            $table->enum('billing_cycle', ['monthly', 'yearly'])->default('monthly');
            $table->string('description')->nullable();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('plans');
    }
};

__create_features_table.php

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('features', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('features');
    }
};

__create_plan_features_table.php

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('plan_features', function (Blueprint $table) {
            $table->id();
            $table->foreignId('plan_id')->constrained('plans')->cascadeOnDelete();
            $table->foreignId('feature_id')->constrained('features')->cascadeOnDelete();
            $table->string('value')->nullable();
            $table->timestamps();

            // Prevent deuplicates
            $table->unique(['plan_id', 'feature_id']);
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('plan_features');
    }
};

Before migration open .env and change DB_CONNECTION=mysql:

.env

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=dynamic_pricing_system_laravel
DB_USERNAME=<db-user>
DB_PASSWORD=<db-password>

Next run the migrate artisan command:

php artisan migrate

 

Open the app/Models/Plan.php model and update like so:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Plan extends Model
{
    protected $fillable = ['name', 'slug', 'price', 'billing_cycle', 'description'];

    public function features()
    {
        return $this->belongsToMany(Feature::class, 'plan_features')
                        ->withPivot(['value'])
                        ->withTimestamps();
    }
}

For mass assignment, i added the $fillable property and the features() relationship.

Let’s do the same for app/Models/Feature.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Feature extends Model
{
    protected $fillable = ['name'];

    public function plans()
    {
        return $this->belongsToMany(Plan::class, 'plan_features')
                    ->withPivot(['value'])
                    ->withTimestamps();
    }
}

 

Step 3: Seeding the Data

Create a custom seeder to insert some pricing data:

php artisan make:seeder PricingSeeder

database/seeders/PricingSeeder.php

<?php

namespace Database\Seeders;

use App\Models\Feature;
use App\Models\Plan;
use Illuminate\Database\Seeder;

class PricingSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $features = [
            'Storage',
            'Users Allowed',
            'Support',
            'Custom Domain'
        ];

        foreach ($features as $name) {
            Feature::create(['name' => $name]);
        }

        $plans = [
            [
                'name' => 'Free',
                'slug' => 'free',
                'price' => 0.00,
                'billing_cycle' => 'monthly',
                'features' => [
                    'Storage' => '1GB',
                    'Users Allowed' => '1',
                    'Support' => 'Email Only'
                ]
            ],
            [
                'name' => 'Basic',
                'slug' => 'basic',
                'price' => 9.99,
                'billing_cycle' => 'monthly',
                'features' => [
                    'Storage' => '10GB',
                    'Users Allowed' => '5',
                    'Support' => 'Email Only'
                ]
            ],
            [
                'name' => 'Pro',
                'slug' => 'pro',
                'price' => 29.99,
                'billing_cycle' => 'monthly',
                'features' => [
                    'Storage' => '100GB',
                    'Users Allowed' => '10',
                    'Support' => 'Email & Chat',
                    'Custom Domain' => 'Yes'
                ]
            ],
        ];

        foreach ($plans as $planData) {
            $plan = Plan::create([
                'name' => $planData['name'],
                'slug' => $planData['slug'],
                'price' => $planData['price'],
                'billing_cycle' => $planData['billing_cycle']
            ]);

            foreach ($planData['features'] as $featureName => $value) {
                $feature = Feature::where('name', $featureName)->first();
                $plan->features()->attach($feature->id, ['value' => $value]);
            }
        }
    }
}

Run the seeder:

php artisan db:seed --class=PricingSeeder

 

Step 4: Display Pricing In The UI

First create the pricing controller:

php artisan make:controller PricingController

app/Http/Controllers/PricingController.php

<?php

namespace App\Http\Controllers;

use App\Models\Plan;
use Illuminate\Http\Request;

class PricingController extends Controller
{
    public function index()
    {
        $plans = Plan::with('features')->get();
        return view('pricing.index', compact('plans'));
    }
}

Register the route:

// routes/web.php
use App\Http\Controllers\PricingController;

Route::get('/pricing', [PricingController::class, 'index'])->name('pricing.index');

Create the view file resources/views/pricing/index.blade.php:

@extends('layouts.app')

@section('content')
<div class="container mx-auto py-10">
    <h1 class="text-4xl font-bold text-center mb-10">Our Pricing Plans</h1>

    <div class="grid grid-cols-1 md:grid-cols-3 gap-8">
        @foreach($plans as $plan)
        <div class="bg-white shadow-lg rounded-2xl p-6 border text-center">
            <h2 class="text-2xl font-semibold mb-4 text-white {{$plan->slug === 'free' ? 'bg-green-500':($plan->slug === 'basic' ? 'bg-orange-500' : 'bg-blue-500')}}">{{ $plan->name }}</h2>
            <p class="text-5xl font-bold mb-2">${{ number_format($plan->price, 2) }}</p>
            <p class="text-gray-500 mb-6">{{ ucfirst($plan->billing_cycle) }} billing</p>
            
            <ul class="text-left mb-6 space-y-2">
                @foreach($plan->features as $feature)
                <li class="flex justify-between border-b pb-1">
                    <span>{{ $feature->name }}</span>
                    <span class="font-semibold">{{ $feature->pivot->value }}</span>
                </li>
                @endforeach
            </ul>

            <a href="#" class="bg-blue-600 text-white px-6 py-2 rounded-xl hover:bg-blue-700 transition">
               @if($plan->slug === 'free')
                            Start Now
                        @else
                            Choose Plan
                        @endif
            </a>
        </div>
        @endforeach
    </div>
</div>
@endsection

Make sure you have tailwindcss installed for the pricing styles to render properly.

Here are a screenshot of the final output:

pricing-tables-output

 

In the next part we will add a simple dashboard to manage plans, you can skip this or continue to part 2

Part 2: Plans CRUD >>

 

0 0 votes
Article Rating

What's your reaction?

Excited
0
Happy
0
Not Sure
0
Confused
0

You may also like

Subscribe
Notify of
guest

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments