Overview

Larapen's add-on system lets you extend the platform with custom functionality: a blog, e-shop, booking system, or any feature you can imagine. Each add-on is a self-contained Laravel package with its own models, services, controllers, routes, views, translations, and migrations. This guide walks you through creating an add-on from scratch.

Don't want to build it yourself? BeDigit, the company behind Larapen, offers custom add-on development services. Request a quote and our team will design, build, and test a custom add-on for your specific needs.

Available add-ons

Larapen ships with 26 add-ons that you can use as references when building your own:

blog, shop, forum, helpcenter, careers, events, faq, partners, portfolio, pricing, team, newsletter, billing, classified, booking, envato, licenses, glossary, gdpr, stats, webmail, stripe, paypal, paddle, momo, adsblockerdetector.

Browse any installed add-on under extensions/addons/ to see real-world examples of the patterns described below.

Prerequisites

  • PHP 8.3+ with a working Larapen installation.
  • Good knowledge of Laravel (Eloquent, controllers, service providers, migrations, Blade).
  • Understanding of the service layer pattern (Controllers → Services → Models).
  • Familiarity with spatie/laravel-translatable (for multilingual content).

Step 1: Create the add-on directory

All add-ons live under extensions/addons/. Create a directory using a lowercase, hyphen-free name:

extensions/addons/testimonials/

Step 2: Create the manifest (addon.json)

Create extensions/addons/testimonials/addon.json:

{
    "name": "testimonials",
    "display_name": "Testimonials",
    "version": "1.0.0",
    "description": "Collect and display customer testimonials on your website",
    "author": "Your Name",
    "requires_core": ">=1.0.0",
    "license_type": "free",
    "dependencies": [],
    "needs_user_account": false,
    "provides": {
        "permissions": [
            "testimonials.view",
            "testimonials.create",
            "testimonials.edit",
            "testimonials.delete",
            "testimonials.settings.view",
            "testimonials.settings.edit"
        ],
        "menu_item_types": ["testimonials"],
        "admin_menu": [
            {
                "label": "Testimonials",
                "icon": "bi-chat-quote",
                "route": "admin.testimonials.items.index",
                "permission": "testimonials.view",
                "children": [
                    { "label": "All Testimonials", "route": "admin.testimonials.items.index" },
                    { "label": "Add New", "route": "admin.testimonials.items.create" },
                    { "label": "Settings", "route": "admin.testimonials.settings" }
                ]
            }
        ],
        "front_menu": {
            "route": "front.testimonials.index",
            "label": "Testimonials"
        }
    },
    "item_id": ""
}

Manifest field reference

FieldTypeDescription
namestringRequired. Unique system slug: must match directory name.
display_namestringRequired. Human-readable name shown in admin.
versionstringSemantic version.
descriptionstringShort description for the admin panel.
authorstringAuthor name.
requires_corestringMinimum Larapen core version.
license_typestring"free" or "paid". Controls sidebar grouping (Features vs Add-ons).
dependenciesarrayOther add-ons that must be active (AND logic: all required).
dependencies_anyarrayOther add-ons where at least one must be active (OR logic).
needs_user_accountboolWhen true, enables front-end user account UI.
provides.permissionsarraySpatie permission names: auto-registered on activation, removed on deactivation.
provides.admin_menuarraySidebar menu items injected into the admin panel.
provides.front_menuobjectSingle { route, label } for the front-end navigation.
provides.menu_item_typesarrayCustom link types available in the menu builder.
provides.user_menuarrayItems for the user account dropdown menu.
item_idstringEnvato item ID. Leave empty for non-marketplace add-ons.

Step 3: Create the full directory structure

extensions/addons/testimonials/
├── addon.json
├── config/
│   └── testimonials.php
├── database/
│   └── migrations/
│       └── 2026_01_01_000001_create_testimonials_table.php
├── resources/
│   ├── lang/
│   │   ├── en/
│   │   │   └── testimonials.php
│   │   └── fr/
│   │       └── testimonials.php
│   └── views/
│       └── admin/
│           ├── items/
│           │   ├── index.blade.php
│           │   ├── create.blade.php
│           │   └── edit.blade.php
│           └── settings.blade.php
└── src/
    ├── TestimonialsServiceProvider.php
    ├── Http/
    │   ├── Controllers/
    │   │   ├── Admin/
    │   │   │   ├── TestimonialController.php
    │   │   │   └── SettingController.php
    │   │   └── Front/
    │   │       └── TestimonialController.php
    │   └── Requests/
    │       ├── StoreTestimonialRequest.php
    │       └── UpdateTestimonialRequest.php
    ├── Models/
    │   └── Testimonial.php
    ├── Routes/
    │   ├── admin.php
    │   └── web.php
    └── Services/
        └── TestimonialService.php

Step 4: Create the Service Provider

The Service Provider is the entry point of your add-on. It registers routes, views, translations, migrations, and config. PSR-4 autoloading is handled automatically: the namespace Addons\Testimonials\ maps to extensions/addons/testimonials/src/.

Create src/TestimonialsServiceProvider.php:

<?php

declare(strict_types=1);

namespace Addons\Testimonials;

use Illuminate\Support\ServiceProvider;

class TestimonialsServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Merge config: makes config('testimonials.*') available
        $this->mergeConfigFrom(__DIR__ . '/../config/testimonials.php', 'testimonials');
    }

    public function boot(): void
    {
        // 1. Database migrations
        $this->loadMigrationsFrom(__DIR__ . '/../database/migrations');

        // 2. Routes (admin + front-end)
        $this->loadRoutesFrom(__DIR__ . '/Routes/admin.php');
        $this->loadRoutesFrom(__DIR__ . '/Routes/web.php');

        // 3. Blade views with namespace (e.g., 'testimonials::admin.items.index')
        $this->loadViewsFrom(__DIR__ . '/../resources/views', 'testimonials');

        // 4. Translations (e.g., __('testimonials::testimonials.admin.title'))
        $this->loadTranslationsFrom(__DIR__ . '/../resources/lang', 'testimonials');

        // 5. Publishable config
        $this->publishes([
            __DIR__ . '/../config/testimonials.php' => config_path('testimonials.php'),
        ], 'testimonials-config');
    }
}
Naming convention: The class must be named {StudlyName}ServiceProvider (e.g., TestimonialsServiceProvider). The AddonManager discovers it automatically based on this convention.

Step 5: Create the migration

Create database/migrations/2026_01_01_000001_create_testimonials_table.php:

<?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('testimonials', function (Blueprint $table) {
            $table->id();
            $table->string('author_name');
            $table->string('author_title')->nullable();
            $table->string('company')->nullable();
            $table->json('content');                  // Translatable (JSON)
            $table->unsignedTinyInteger('rating')->default(5);
            $table->boolean('is_featured')->default(false);
            $table->boolean('is_active')->default(true);
            $table->unsignedInteger('position')->default(0);
            $table->timestamps();

            $table->index(['is_active', 'position']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('testimonials');
    }
};
Important convention: All add-on tables MUST be prefixed with the add-on name (e.g., testimonials_, blog_posts, shop_orders) to prevent table name collisions. For a simple add-on with a single table, the add-on name itself can serve as the table name. Translatable fields must use the json column type.

Step 6: Create the Model

Create src/Models/Testimonial.php:

<?php

declare(strict_types=1);

namespace Addons\Testimonials\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;

class Testimonial extends Model
{
    use HasFactory, HasTranslations;

    protected $table = 'testimonials';    // ALWAYS set explicitly

    public array $translatable = ['content'];

    protected $fillable = [
        'author_name',
        'author_title',
        'company',
        'content',
        'rating',
        'is_featured',
        'is_active',
        'position',
    ];

    protected function casts(): array
    {
        return [
            'rating'      => 'integer',
            'is_featured' => 'boolean',
            'is_active'   => 'boolean',
            'position'    => 'integer',
        ];
    }

    // Scopes
    public function scopeActive(Builder $query): Builder
    {
        return $query->where('is_active', true);
    }

    public function scopeOrdered(Builder $query): Builder
    {
        return $query->orderBy('position');
    }

    public function scopeFeatured(Builder $query): Builder
    {
        return $query->where('is_featured', true);
    }
}

Step 7: Create the Service

Create src/Services/TestimonialService.php: all business logic goes here, never in controllers:

<?php

declare(strict_types=1);

namespace Addons\Testimonials\Services;

use Addons\Testimonials\Models\Testimonial;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;

class TestimonialService
{
    // Admin methods
    public function getAllPaginated(int $perPage = 15): LengthAwarePaginator
    {
        return Testimonial::query()
            ->ordered()
            ->paginate($perPage);
    }

    // Front-end methods
    public function getActive(?int $limit = null): Collection
    {
        $query = Testimonial::query()->active()->ordered();

        if ($limit) {
            $query->limit($limit);
        }

        return $query->get();
    }

    public function getFeatured(int $limit = 6): Collection
    {
        return Testimonial::query()
            ->active()
            ->featured()
            ->ordered()
            ->limit($limit)
            ->get();
    }

    // CRUD
    public function create(array $data): Testimonial
    {
        return Testimonial::create($data);
    }

    public function update(Testimonial $testimonial, array $data): Testimonial
    {
        $testimonial->update($data);
        return $testimonial;
    }

    public function delete(Testimonial $testimonial): bool
    {
        return (bool) $testimonial->delete();
    }
}

Step 8: Create the FormRequest

Create src/Http/Requests/StoreTestimonialRequest.php: all validation must be in FormRequest classes:

<?php

declare(strict_types=1);

namespace Addons\Testimonials\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreTestimonialRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        $locales = active_locale_codes();
        $defaultLocale = default_locale();

        $rules = [
            'author_name'  => ['required', 'string', 'max:255'],
            'author_title' => ['nullable', 'string', 'max:255'],
            'company'      => ['nullable', 'string', 'max:255'],
            'rating'       => ['required', 'integer', 'min:1', 'max:5'],
            'is_featured'  => ['nullable', 'boolean'],
            'is_active'    => ['nullable', 'boolean'],
            'position'     => ['nullable', 'integer', 'min:0'],
        ];

        // Translatable fields: required only for default locale
        foreach ($locales as $locale) {
            $required = $locale === $defaultLocale ? 'required' : 'nullable';
            $rules["content.{$locale}"] = [$required, 'string', 'max:2000'];
        }

        return $rules;
    }
}

Step 9: Create the Controllers

Admin controller

Create src/Http/Controllers/Admin/TestimonialController.php:

<?php

declare(strict_types=1);

namespace Addons\Testimonials\Http\Controllers\Admin;

use Addons\Testimonials\Http\Requests\StoreTestimonialRequest;
use Addons\Testimonials\Http\Requests\UpdateTestimonialRequest;
use Addons\Testimonials\Models\Testimonial;
use Addons\Testimonials\Services\TestimonialService;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;

class TestimonialController extends Controller
{
    public function __construct(
        protected TestimonialService $testimonialService,
    ) {}

    public function index(): View
    {
        abort_unless(auth()->user()->can('testimonials.view'), 403);

        $testimonials = $this->testimonialService->getAllPaginated();

        // Use the add-on view namespace
        return view('testimonials::admin.items.index', compact('testimonials'));
    }

    public function create(): View
    {
        abort_unless(auth()->user()->can('testimonials.create'), 403);

        return view('testimonials::admin.items.create');
    }

    public function store(StoreTestimonialRequest $request): RedirectResponse
    {
        abort_unless(auth()->user()->can('testimonials.create'), 403);

        $this->testimonialService->create($request->validated());

        return redirect()->route('admin.testimonials.items.index')
            ->with('success', __('admin.messages.created', [
                'item' => __('testimonials::testimonials.admin.title_singular'),
            ]));
    }

    public function edit(Testimonial $testimonial): View
    {
        abort_unless(auth()->user()->can('testimonials.edit'), 403);

        return view('testimonials::admin.items.edit', compact('testimonial'));
    }

    public function update(
        UpdateTestimonialRequest $request,
        Testimonial $testimonial,
    ): RedirectResponse {
        abort_unless(auth()->user()->can('testimonials.edit'), 403);

        $this->testimonialService->update($testimonial, $request->validated());

        return redirect()->route('admin.testimonials.items.index')
            ->with('success', __('admin.messages.updated', [
                'item' => __('testimonials::testimonials.admin.title_singular'),
            ]));
    }

    public function destroy(Testimonial $testimonial): RedirectResponse
    {
        abort_unless(auth()->user()->can('testimonials.delete'), 403);

        $this->testimonialService->delete($testimonial);

        return redirect()->route('admin.testimonials.items.index')
            ->with('success', __('admin.messages.deleted', [
                'item' => __('testimonials::testimonials.admin.title_singular'),
            ]));
    }
}

Front-end controller

Create src/Http/Controllers/Front/TestimonialController.php:

<?php

declare(strict_types=1);

namespace Addons\Testimonials\Http\Controllers\Front;

use Addons\Testimonials\Services\TestimonialService;
use App\Http\Controllers\Controller;
use App\Services\SeoService;
use App\Services\ThemeService;
use Illuminate\View\View;

class TestimonialController extends Controller
{
    public function __construct(
        protected TestimonialService $testimonialService,
        protected ThemeService $themeService,
        protected SeoService $seoService,
    ) {}

    public function index(): View
    {
        $testimonials = $this->testimonialService->getActive();

        $seo = $this->seoService->getMetaTags([
            'title'       => __('testimonials::testimonials.front.title'),
            'description' => __('testimonials::testimonials.front.meta_description'),
        ]);

        // Use ThemeService::view() for theme-aware view resolution
        return $this->themeService->view('pages.testimonials.index', compact(
            'testimonials', 'seo',
        ));
    }
}
Key pattern: Front-end controllers use $this->themeService->view() instead of view(). This enables the 3-tier view resolution: active theme → default theme → standard Laravel views.

Step 10: Create the Routes

Admin routes (src/Routes/admin.php)

<?php

use Addons\Testimonials\Http\Controllers\Admin\TestimonialController;
use Addons\Testimonials\Http\Controllers\Admin\SettingController;
use Illuminate\Support\Facades\Route;

Route::prefix('admin/testimonials')
    ->middleware(['web', 'auth', 'admin.access'])
    ->name('admin.testimonials.')
    ->group(function () {
        Route::resource('items', TestimonialController::class)
            ->except(['show'])
            ->parameters(['items' => 'testimonial']);

        Route::get('settings', [SettingController::class, 'edit'])->name('settings');
        Route::put('settings', [SettingController::class, 'update'])->name('settings.update');
    });

Front-end routes (src/Routes/web.php)

<?php

use Addons\Testimonials\Http\Controllers\Front\TestimonialController;
use Illuminate\Support\Facades\Route;

$enabled = (bool) setting('testimonials_public_page_enabled', '1');

if ($enabled) {
    // Localized routes: /fr/testimonials
    Route::prefix('{locale}')
        ->where(['locale' => '[a-z]{2}'])
        ->middleware(['web', 'set-locale', 'active-theme'])
        ->group(function () {
            Route::get('testimonials', [TestimonialController::class, 'index'])
                ->name('front.testimonials.index.localized');
        });

    // Non-localized (default language): /testimonials
    Route::middleware(['web', 'set-locale', 'active-theme'])
        ->group(function () {
            Route::get('testimonials', [TestimonialController::class, 'index'])
                ->name('front.testimonials.index');
        });
}
Route pattern: Every front-end route must be defined twice; once with the {locale} prefix (for non-default languages) and once without (for the default language). Both share the same middleware stack: web, set-locale, active-theme.

Step 11: Create translations

Create resources/lang/en/testimonials.php:

<?php

declare(strict_types=1);

return [
    'admin' => [
        'title'          => 'Testimonials',
        'title_singular' => 'Testimonial',
        'subtitle'       => 'Manage customer testimonials',
        'add_new'        => 'Add Testimonial',
        'edit'           => 'Edit Testimonial',
        'empty'          => 'No testimonials found.',
        // ... field labels, messages, etc.
    ],
    'front' => [
        'title'            => 'Testimonials',
        'meta_description' => 'See what our customers say about us.',
        // ... front-end strings
    ],
];

Access translations with the double-colon namespace: __('testimonials::testimonials.admin.title')

Step 12: Create admin views

Admin views extend the core admin layout. Create resources/views/admin/items/index.blade.php:

@extends('admin.layouts.master')

@section('title', __('testimonials::testimonials.admin.title'))
@section('page-title', __('testimonials::testimonials.admin.title'))
@section('page-subtitle', __('testimonials::testimonials.admin.subtitle'))

@section('breadcrumb')
    <span>{{ __('testimonials::testimonials.admin.title') }}</span>
@endsection

@section('page-actions')
    @can('testimonials.create')
        <a href="{{ route('admin.testimonials.items.create') }}"
           class="btn btn-primary btn-sm">
            <i class="bi bi-plus-lg"></i>
            {{ __('testimonials::testimonials.admin.add_new') }}
        </a>
    @endcan
@endsection

@section('content')
    {{-- Your table, pagination, empty state --}}
@endsection

Step 13: Create theme views

Front-end views should be provided by the themes. Place default views in your active theme(s):

extensions/themes/default/views/pages/testimonials/index.blade.php

These views are resolved by ThemeService::view('pages.testimonials.index').

Step 14: Create the config file

Create config/testimonials.php:

<?php

return [
    'list_per_page'     => 12,
    'show_rating_stars' => true,
    'max_content_length' => 500,
];

Step 15: Activate and test

  1. Go to Add-ons in the admin panel.
  2. Click "Sync" to discover your new add-on.
  3. Click "Activate": this will:
    • Run your migrations automatically.
    • Register your Spatie permissions and grant them to the admin role.
    • Register your Service Provider (routes, views, translations).
    • Add your menu items to the admin sidebar.
  4. Your add-on's admin pages should now be accessible.

Architecture rules

  • NEVER put business logic in controllers: always delegate to a Service class.
  • ALWAYS use FormRequest classes for validation; never validate inline.
  • ALWAYS set protected $table explicitly on every model.
  • Use spatie/laravel-translatable for all user-facing text fields.
  • Prefix database tables with the add-on name to avoid collisions.
  • Use __('namespace::file.key') for all translatable strings; never hardcode text.
  • Use Eloquent scopes for reusable query filters (scopeActive, scopeOrdered).
  • Use PHP 8.3+ features: typed properties, enums, readonly, named arguments.
  • Add declare(strict_types=1) to all PHP files.

Payment gateway add-ons

If you're building a payment gateway add-on (e.g., PayPal, Mollie), your add-on must implement the App\Contracts\PaymentGatewayInterface:

interface PaymentGatewayInterface
{
    public function getId(): string;
    public function getName(): string;
    public function getDisplayName(): string;
    public function initialize(array $config): void;
    public function isAvailable(): bool;
    public function createPaymentIntent(Order $order): PaymentResult;
    public function confirmPayment(string $paymentIntentId): PaymentResult;
    public function handleWebhook(Request $request): WebhookResult;
    public function refund(Transaction $transaction, float $amount, ?string $reason = null): RefundResult;
    public function getConfigFields(): array;
    public function getScripts(): array;
    public function getPaymentFormView(): ?string;
}

The Shop add-on automatically discovers payment gateways from installed and active add-ons that implement this interface.

Complete checklist

 Item
addon.json manifest with permissions, admin_menu, front_menu
config/{name}.php with defaults
Service Provider (register config, boot routes/views/translations/migrations)
Database migrations (tables prefixed, JSON columns for translatables)
Models (HasTranslations, explicit $table, scopes, casts)
Services (all business logic)
FormRequest validation classes
Admin controllers + routes (prefixed, with auth/admin.access middleware)
Front controllers + routes (localized + non-localized variants)
Translations in en/ and fr/
Admin views (extending admin.layouts.master)
Theme views in each active theme's views/pages/
Theme SCSS in each active theme's assets/scss/pages/
Run npx vite build after adding new SCSS/JS
Activate add-on and test all routes
Need expert help? BeDigit offers custom add-on development; from simple widgets to full-featured modules. Request a free quote →

Was this article helpful?

Thank you for your feedback!

Still need help? Create a support ticket

Create a Ticket