Backend Development

Implementing a Pricing System in Your Laravel Project: part 2

Implementing a Pricing System in Your Laravel Project

In this part of implementing a pricing system, we will create a simple dashboard to manage plans CRUD. We will be using Laravel breeze as it provides a ready scaffolding with auth.

 

 

<< Previous Part

Step 1: Install Laravel Breeze

composer require laravel/breeze --dev

Next run the breeze:install command and select Vue with Inertia:

php artisan breeze:install

 Which Breeze stack would you like to install? ───────────────┐
 │   ○ Blade with Alpine                                        │
 │   ○ Livewire (Volt Class API) with Alpine                    │
 │   ○ Livewire (Volt Functional API) with Alpine               │
 │   ○ React with Inertia                                       │
 │ › ● Vue with Inertia                                         │
 │   ○ API only    

After selecting vue with Inertia wait some time and then run:

npm install && npm run dev

This gives you:

  • /login, /register, /dashboard

  • Vue 3 + Inertia setup

Also the breeze: install already generates some Vue views and components in resources/js/.

You can now log in as a user and access /dashboard.

register-new-user

Update HandleInertiaRequests::share() method. The share() method allows to share global data to be accessible in our Vue app, in this case let’s share the flash messages:

app/Http/Middleware/HandleInertiaRequests.php:

public function share(Request $request): array
    {
        return [
            ...parent::share($request),
            ....
            ....
            'flash' => [
                'success' => fn () => $request->session()->get('success')
            ]
        ];
    }

 

 

 

Step 2: Restrict Admin Access

For simplicity, we’ll assume only users with is_admin = true can access admin pages.

Add is_admin column to users table:

php artisan make:migration add_is_admin_to_users_table --table=users

Edit migration:

public function up(): void
{
    Schema::table('users', function (Blueprint $table) {
        $table->boolean('is_admin')->default(false)->after('email');
    });
}
php artisan migrate

Edit the user already registered into the db and set the is_admin=1

 Create a new middleware that restrict access access to admin users only:
php artisan make:middleware AdminMiddleware

app/Http/Middleware/AdminMiddleware.php:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class AdminMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        if (!auth()->check() || !auth()->user()->is_admin) {
            abort(403, 'Unauthorized');
        }
        
        return $next($request);
    }
}

Register it in bootstrap/app.php:

return Application::configure(basePath: dirname(__DIR__))
    
    ->withMiddleware(function (Middleware $middleware): void {
      
          $middleware->alias([
            'admin' => \App\Http\Middleware\AdminMiddleware::class
        ]);
   })
   

 

Step 3: Admin Controllers

Create the admin controllers for plans and features CRUD:

php artisan make:controller Admin/PlanController
php artisan make:controller Admin/FeatureController

app/Http/Controllers/Admin/PlanController.php

<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\Feature;
use App\Models\Plan;
use Illuminate\Http\Request;
use Inertia\Inertia;

class PlanController extends Controller
{
    public function index()
    {
        $plans = Plan::with('features')->get();
        return Inertia::render('Admin/Plans/Index', [
            'plans' => $plans
        ]);
    }

    public function create()
    {
        $features = Feature::all();
        return Inertia::render('Admin/Plans/Create', compact('features'));
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required',
            'slug' => 'required|unique:plans',
            'price' => 'required|numeric',
            'billing_cycle' => 'required|in:monthly,yearly',
        ]);

        $plan = Plan::create($validated);

        // Attach selected features
        if ($request->has('features')) {
            foreach ($request->features as $feature) {
                $plan->features()->attach($feature['id'], ['value' => $feature['value'] ?? null]);
            }
        }

        return redirect()->route('admin.plans.index')->with('success', 'Plan created!');
    }

    public function edit(Plan $plan)
    {
        $plan->load('features');

        $features = Feature::all();

        return Inertia::render('Admin/Plans/Edit', compact('plan', 'features'));
    }

    public function update(Request $request, Plan $plan)
    {
        $validated = $request->validate([
            'name' => 'required|string',
            'slug' => 'required|string|unique:plans,slug,' . $plan->id,
            'price' => 'required|numeric',
            'billing_cycle' => 'required|in:monthly,yearly',
            'description' => 'nullable|string',
        ]);

        $plan->update($validated);

        // Sync features with values
        $syncData = [];
        foreach ($request->features as $f) {
            if (!empty($f['value'])) {
                $syncData[$f['id']] = ['value' => $f['value']];
            }
        }
        $plan->features()->sync($syncData);

        return redirect()->route('admin.plans.index')->with('success', 'Plan updated!');
    }

    public function destroy(Plan $plan)
    {
        $plan->delete();
        return back()->with('success', 'Plan deleted.');
    }
}

 

app/Http/Controllers/Admin/FeatureController.php

<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\Feature;
use Illuminate\Http\Request;
use Inertia\Inertia;

class FeatureController extends Controller
{
    public function index()
    {
        return Inertia::render('Admin/Features/Index', [
            'features' => Feature::all()
        ]);
    }


    public function store(Request $request)
    {
        $request->validate(['name' => 'required|string|max:255']);

        Feature::create($request->only('name'));

        return back()->with('success', 'Feature added!');
    }

    public function update(Request $request, Feature $feature)
    {
        $validated = $request->validate(['name' => 'required|string|max:255']);

        $feature->update($validated);

        return back()->with('success', 'Feature updated!');
    }

    public function destroy(Feature $feature)
    {
        $feature->delete();
        return back()->with('success', 'Feature deleted.');
    }
}

As shown above the full controller logic for plan and features handling.

 

Step 4: Admin Routes 

Create an admin route group in routes/web.php:

Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(function () {
    Route::get('/', fn() => inertia('Admin/Dashboard'))->name('dashboard');
    Route::resource('plans', \App\Http\Controllers\Admin\PlanController::class);
    Route::resource('features', \App\Http\Controllers\Admin\FeatureController::class);
});

 

Step 5: Vue(Inertia) Admin Pages

resources/js/Pages/Admin/Dashboard.vue

<template>
    <Head title="Dashboard" />

    <AuthenticatedLayout>

        <div class="p-8">
            <h1 class="text-3xl font-bold mb-4">Admin Dashboard</h1>
            <p class="text-gray-600">Welcome, manage your pricing system here.</p>

            <div class="mt-6 space-x-4">
                <Link href="/admin/plans" class="text-blue-600 underline">Manage Plans</Link>
                <Link href="/admin/features" class="text-blue-600 underline">Manage Features</Link>
            </div>
        </div>
    </AuthenticatedLayout>
</template>

<script setup>
import {Head, Link} from '@inertiajs/vue3'
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
</script>

Also open resources/js/Layouts/AuthenticatedLayout.vue and add the links for plans and features after dashboard <NavLink />

<NavLink
                                   :href="route('dashboard')"
                                   :active="route().current('dashboard')"
                               >
                                   Dashboard
                               </NavLink>

                               <NavLink
                                   :href="route('admin.plans.index')"
                                   :active="route().current('admin.plans.index')"
                               >
                                   Manage Plans
                               </NavLink>

                               <NavLink
                                   :href="route('admin.features.index')"
                                   :active="route().current('admin.features.index')"
                               >
                                   Manage Features
                               </NavLink>

 

Plans CRUD

resources/js/Pages/Admin/Plans/Index.vue

<template>
    <Head title="Plans" />

    <AuthenticatedLayout>

        <div class="p-8">
            <div v-if="$page.props?.flash?.success" class="bg-green-400 px-3 py-2 rounded-md mb-3">
                {{ $page.props.flash.success }}
            </div>

            <div class="flex justify-between mb-6">
                <h1 class="text-2xl font-bold">Plans</h1>
                <Link href="/admin/plans/create" class="bg-blue-600 text-white px-4 py-2 rounded">Add Plan</Link>
            </div>

            <table class="w-full border text-left">
                <thead class="bg-gray-100">
                <tr>
                    <th class="p-2">Name</th>
                    <th class="p-2">Price</th>
                    <th class="p-2">Billing Cycle</th>
                    <th class="p-2">Features</th>
                    <th class="p-2">Actions</th>
                </tr>
                </thead>
                <tbody>
                <tr v-for="plan in plans" :key="plan.id" class="border-t">
                    <td class="p-2">{{ plan.name }}</td>
                    <td class="p-2">${{ plan.price }}</td>
                    <td class="p-2 capitalize">{{ plan.billing_cycle }}</td>
                    <td class="p-2">
                        <ul>
                            <li v-for="f in plan.features" :key="f.id">{{ f.name }}: {{ f.pivot.value }}</li>
                        </ul>
                    </td>
                    <td class="p-2">
                        <Link :href="`/admin/plans/${plan.id}/edit`" class="text-blue-600 mr-3">Edit</Link>
                        <Link as="button" method="delete" :href="`/admin/plans/${plan.id}`" class="text-red-600">Delete</Link>
                    </td>
                </tr>
                </tbody>
            </table>
        </div>
    </AuthenticatedLayout>
</template>

<script setup>
import {Head, Link} from '@inertiajs/vue3'
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
defineProps({ plans: Array })
</script>

 

Create.vue — Add a New Plan

resources/js/Pages/Admin/Plans/Create.vue

<template>
    <Head title="Plans | Create" />

    <AuthenticatedLayout>
        <div class="p-8 max-w-3xl mx-auto">
            <h1 class="text-2xl font-bold mb-6">Create New Plan</h1>

            <form @submit.prevent="submit" class="space-y-6">
                <!-- Basic Info -->
                <div>
                    <label class="block font-medium mb-1">Name</label>
                    <input v-model="form.name" class="w-full border rounded px-3 py-2" />
                    <div v-if="errors.name" class="text-red-600 text-sm">{{ errors.name }}</div>
                </div>

                <div>
                    <label class="block font-medium mb-1">Slug</label>
                    <input v-model="form.slug" class="w-full border rounded px-3 py-2" />
                    <div v-if="errors.slug" class="text-red-600 text-sm">{{ errors.slug }}</div>

                </div>

                <div class="flex space-x-4">
                    <div class="flex-1">
                        <label class="block font-medium mb-1">Price</label>
                        <input v-model="form.price" type="number" class="w-full border rounded px-3 py-2" />
                        <div v-if="errors.price" class="text-red-600 text-sm">{{ errors.price }}</div>
                    </div>
                    <div class="flex-1">
                        <label class="block font-medium mb-1">Billing Cycle</label>
                        <select v-model="form.billing_cycle" class="w-full border rounded px-3 py-2">
                            <option value="monthly">Monthly</option>
                            <option value="yearly">Yearly</option>
                        </select>
                        <div v-if="errors.billing_cycle" class="text-red-600 text-sm">{{ errors.billing_cycle }}</div>
                    </div>
                </div>

                <div>
                    <label class="block font-medium mb-1">Description</label>
                    <textarea v-model="form.description" class="w-full border rounded px-3 py-2"></textarea>
                </div>

                <!-- Features -->
                <div>
                    <h2 class="text-lg font-semibold mb-2">Features</h2>
                    <div v-for="feature in features" :key="feature.id" class="flex items-center mb-2 space-x-2">
                        <label class="w-1/3">{{ feature.name }}</label>
                        <input v-model="feature.value" placeholder="Enter value (e.g., 10GB, Yes)"
                               class="border flex-1 rounded px-3 py-2" />
                    </div>
                </div>

                <div class="flex justify-end space-x-3">
                    <Link href="/admin/plans" class="px-4 py-2 border rounded">Cancel</Link>
                    <button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded">Save Plan</button>
                </div>
            </form>
        </div>

    </AuthenticatedLayout>
</template>

<script setup>
import { Link, useForm, Head } from '@inertiajs/vue3'
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";

const props = defineProps({ features: Array, errors: Object })

const form = useForm({
    name: '',
    slug: '',
    price: '',
    billing_cycle: 'monthly',
    description: '',
    features: [],
})

// copy features list with value fields
form.features = [...props.features.map(f => ({ ...f, value: '' }))]

function submit() {
    form.post('/admin/plans')
}
</script>

Edit.vue — Updating a Plan

resources/js/Pages/Admin/Plans/Edit.vue

<template>
    <Head title="Dashboard" />

    <AuthenticatedLayout>

        <div class="p-8 max-w-3xl mx-auto">
            <h1 class="text-2xl font-bold mb-6">Edit Plan</h1>

            <form @submit.prevent="submit" class="space-y-6">
                <div>
                    <label class="block font-medium mb-1">Name</label>
                    <input v-model="form.name" class="w-full border rounded px-3 py-2" />
                    <div v-if="errors.name" class="text-red-600 text-sm">{{ errors.name }}</div>
                </div>

                <div>
                    <label class="block font-medium mb-1">Slug</label>
                    <input v-model="form.slug" class="w-full border rounded px-3 py-2" />
                    <div v-if="errors.slug" class="text-red-600 text-sm">{{ errors.slug }}</div>
                </div>

                <div class="flex space-x-4">
                    <div class="flex-1">
                        <label class="block font-medium mb-1">Price</label>
                        <input v-model="form.price" type="number" class="w-full border rounded px-3 py-2" />
                        <div v-if="errors.price" class="text-red-600 text-sm">{{ errors.price }}</div>
                    </div>
                    <div class="flex-1">
                        <label class="block font-medium mb-1">Billing Cycle</label>
                        <select v-model="form.billing_cycle" class="w-full border rounded px-3 py-2">
                            <option value="monthly">Monthly</option>
                            <option value="yearly">Yearly</option>
                        </select>
                        <div v-if="errors.billing_cycle" class="text-red-600 text-sm">{{ errors.billing_cycle }}</div>
                    </div>
                </div>

                <div>
                    <label class="block font-medium mb-1">Description</label>
                    <textarea v-model="form.description" class="w-full border rounded px-3 py-2"></textarea>
                </div>

                <!-- Features -->
                <div>
                    <h2 class="text-lg font-semibold mb-2">Features</h2>
                    <div v-for="(feature, i) in form.features" :key="feature.id" class="flex items-center mb-2 space-x-2">
                        <label class="w-1/3">{{ feature.name }}</label>
                        <input type="text" v-model="form.features[i].value" placeholder="Enter value (e.g., 10GB, Yes)"
                               class="border flex-1 rounded px-3 py-2" />
                    </div>
                </div>

                <div class="flex justify-end space-x-3">
                    <Link href="/admin/plans" class="px-4 py-2 border rounded">Cancel</Link>
                    <button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded">Update Plan</button>
                </div>
            </form>
        </div>
    </AuthenticatedLayout>
</template>

<script setup>
import {Head, Link, useForm} from '@inertiajs/vue3'
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";

const props = defineProps({ plan: Object, features: Array, errors: Object })

const mergedFeatures = props.features.map(f => {
    const existing = props.plan.features.find(pf => pf.id === f.id)
    return {
        id: f.id,
        name: f.name,
        value: existing ? existing.pivot.value : '',
    }
})

// merge features with values from existing plan
const form = useForm({
    id: props.plan.id,
    name: props.plan.name,
    slug: props.plan.slug,
    price: props.plan.price,
    billing_cycle: props.plan.billing_cycle,
    description: props.plan.description,
    features: mergedFeatures,
})

function submit() {
    form.put(`/admin/plans/${form.id}`)
}
</script>

 

Features CRUD

Listing / Adding / Updating and Deleting a Feature

resources/js/Pages/Admin/Features/Index.vue

<template>
    <Head title="Features" />

    <AuthenticatedLayout>

        <div class="p-8">
            <div v-if="$page.props?.flash?.success" class="bg-green-400 px-3 py-2 rounded-md mb-3">
                {{ $page.props.flash.success }}
            </div>

            <h1 class="text-2xl font-bold mb-4">Features</h1>

            <form @submit.prevent="submit" class="mb-4">
                <div class="flex space-x-2">
                    <input v-model="form.name" type="text" placeholder="Feature name"
                           class="border rounded px-3 py-2 flex-1" />
                    <button class="bg-blue-600 text-white px-4 py-2 rounded">{{editId ? 'Update' : 'Add'}}</button>
                </div>
                <div v-if="errors.name" class="text-red-600 text-sm mt-2">{{ errors.name }}</div>
            </form>

            <table class="w-full border text-left">
                <thead class="bg-gray-100">
                    <tr>
                        <th>Feature</th>
                        <th>Action</th>
                    </tr>
                </thead>
                <tbody>
                    <tr v-for="feature in features" :key="feature.id" class="border-t">
                        <td class="p-2">{{ feature.name }}</td>
                        <td class="p-2">
                            <a href="#" @click.prevent="handleEdit(feature)" class="text-blue-600 mr-3">Edit</a>
                            <Link as="button" method="delete" :href="`/admin/features/${feature.id}`" class="text-red-600">Delete</Link>
                        </td>
                    </tr>
                </tbody>
            </table>
        </div>
    </AuthenticatedLayout>
</template>

<script setup>
import {ref} from "vue";
import {Head, Link, useForm} from '@inertiajs/vue3'
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";

defineProps({ features: Array, errors: Object })

const form = useForm({ name: '' });
const editId = ref(null);

function submit() {
    if(editId.value) {
        form.put(`/admin/features/${editId.value}`, { onSuccess: () => {
                form.reset();
                editId.value = null;
            }
        })
    } else {
        form.post('/admin/features', { onSuccess: () => form.reset() })
    }
}

function handleEdit(feature) {
    form.name = feature.name;
    editId.value = feature.id;
}
</script>

Full Sourcecode

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