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.
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
| Field | Type | Description |
|---|---|---|
name | string | Required. Unique system slug: must match directory name. |
display_name | string | Required. Human-readable name shown in admin. |
version | string | Semantic version. |
description | string | Short description for the admin panel. |
author | string | Author name. |
requires_core | string | Minimum Larapen core version. |
license_type | string | "free" or "paid". Controls sidebar grouping (Features vs Add-ons). |
dependencies | array | Other add-ons that must be active (AND logic: all required). |
dependencies_any | array | Other add-ons where at least one must be active (OR logic). |
needs_user_account | bool | When true, enables front-end user account UI. |
provides.permissions | array | Spatie permission names: auto-registered on activation, removed on deactivation. |
provides.admin_menu | array | Sidebar menu items injected into the admin panel. |
provides.front_menu | object | Single { route, label } for the front-end navigation. |
provides.menu_item_types | array | Custom link types available in the menu builder. |
provides.user_menu | array | Items for the user account dropdown menu. |
item_id | string | Envato 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');
}
}
{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');
}
};
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',
));
}
}
$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');
});
}
{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
- Go to Add-ons in the admin panel.
- Click "Sync" to discover your new add-on.
- 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.
- 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 $tableexplicitly on every model. - Use
spatie/laravel-translatablefor 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 |