Tutorial: Build Your First TruCMS Plugin

You will build Testimonials, a real plugin that touches every major extension point: its own table, admin screens, sidebar menu, permissions, a settings page, a dashboard card, a page-builder block and a sidebar widget — plus tests. Each step explains what the platform does with the code you write, so you can reuse the pattern for your own plugin.

The finished plugin is included

Every code block below is taken from the example Testimonials plugin, and the downloadable testimonials-1.0.0.zip is the same plugin packaged for the Plugin Manager. The plugin ships with its Pest tests (step 11), which run once the plugin is installed and enabled. Install the ZIP from the Plugin Manager if you want to skip typing.

What you will build

CapabilityExtension point usedStep
A testimonials table with a model, factory and scopesModule migrations, BaseModel2
Admin list / create / edit / delete / publishModule routes, AdminTheme, ->can()3–4
Four permissionsPermission migration2
Sidebar entryMenuManager on AdminBooting5
Settings page under SettingsSettingsManager6
Dashboard cardDashboard registry + Arrilot widget7
A block editors drag into pagesShortcode registry + Blade component8
A sidebar/footer widgetWidgets registry9
Feature testsPest + project helpers11

Final file tree

Modules/Testimonials/
  module.json                       manifest read by the Plugin Manager
  composer.json                     PSR-4 map (validated on upload)
  config/config.php                 merged into config('testimonials')
  lang/en/testimonials.php          strings, namespace testimonials::
  routes/
    admin.php                       admin routes (permission gated)
    breadcrumbs.php                 one breadcrumb per page route
  database/
    migrations/..._create_testimonials_table.php
    migrations/..._add_testimonials_permissions.php
    factories/TestimonialFactory.php
  app/
    Providers/TestimonialsServiceProvider.php   the hook point
    Providers/RouteServiceProvider.php
    Models/Testimonial.php
    Http/Controllers/Admin/TestimonialController.php
    Http/Requests/TestimonialRequest.php
    Helpers/TestimonialsMenu.php  TestimonialsSettings.php
    Shortcodes/TestimonialsShortcode.php
    View/Components/Testimonials.php
    Widgets/Admin/TestimonialsCounterWidget.php
    Widgets/Frontend/TestimonialsWidget.php
  resources/views/
    admin/index.blade.php  form.blade.php
    components/testimonials.blade.php
    widgets/admin/counter.blade.php  list.blade.php
    widgets/frontend/list.blade.php
  tests/Feature/TestimonialsTest.php

Prerequisites

Step 1 — The manifest and autoload map

Create Modules/Testimonials with these three files. (You can scaffold the folder with php artisan module:make Testimonials, but you will replace most generated files, so writing them by hand is just as fast.)

Modules/Testimonials/module.json

{
    "name": "Testimonials",
    "alias": "testimonials",
    "description": "Collect customer testimonials, manage them in the admin panel and show them anywhere with a page-builder block, a widget or a dashboard counter.",
    "version": "1.0.0",
    "author": "Acme Studio",
    "author_url": "https://example.com",
    "support_url": "https://example.com/support",
    "keywords": [
        "testimonials",
        "reviews",
        "social-proof"
    ],
    "priority": 2000,
    "requires": {},
    "public_paths": [],
    "providers": [
        "Modules\\Testimonials\\Providers\\TestimonialsServiceProvider"
    ]
}

What the platform does with it. The Plugin Manager's PluginMetadataReader validates this file on install: name must match ^[A-Z][A-Za-z0-9]{1,63}$ and be the folder name; alias (^[a-z][a-z0-9-]{1,63}$) becomes your view, config and translation namespace; description is mandatory; version must be a valid Composer version; every entry of providers must live under Modules\Testimonials\. priority only affects load order; requires (alias → Composer constraint) lets you depend on other plugins; public_paths reserves first URL segments for public routes (we have none). Full reference: Manifest reference.

Modules/Testimonials/composer.json

{
    "name": "acme/testimonials",
    "description": "Testimonials plugin for TruCMS.",
    "type": "library",
    "license": "proprietary",
    "authors": [
        {
            "name": "Acme Studio",
            "email": "[email protected]"
        }
    ],
    "require": {
        "php": "^8.3"
    },
    "autoload": {
        "psr-4": {
            "Modules\\Testimonials\\": "app/",
            "Modules\\Testimonials\\Database\\Factories\\": "database/factories/",
            "Modules\\Testimonials\\Database\\Seeders\\": "database/seeders/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Modules\\Testimonials\\Tests\\": "tests/"
        }
    }
}

The upload validator checks that autoload.psr-4 maps Modules\Testimonials\ to app/. Because the root composer.json uses wikimedia/composer-merge-plugin with Modules/*/composer.json, this map is merged into the application autoloader the next time you run composer dump-autoload (step 10).

Modules/Testimonials/config/config.php

<?php

declare(strict_types=1);

return [
    'name' => 'Testimonials',

    // Admin URL: /{dotartisan.admin_prefix}/{admin_prefix}
    'admin_prefix' => 'testimonials',

    // Defaults used until an administrator saves the settings screen.
    'default_limit' => 6,
    'max_limit' => 24,
];

Anything returned here is merged under config('testimonials'), because the provider calls mergeConfigFrom(). Keep tunable defaults here; keep administrator-editable values in settings (step 6).

Step 2 — Table, permissions, model, factory

Module migrations are discovered automatically (ModuleServiceProvider::boot() calls loadMigrationsFrom()), so a plain php artisan migrate runs them as soon as the plugin is active.

Modules/Testimonials/database/migrations/2026_10_21_000001_create_testimonials_table.php

<?php

declare(strict_types=1);

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): void {
            $table->id();
            $table->string('author_name');
            $table->string('author_role')->nullable();
            $table->string('company')->nullable();
            $table->text('quote');
            $table->unsignedTinyInteger('rating')->default(5);
            $table->boolean('is_published')->default(true);
            $table->unsignedInteger('sort_order')->default(0);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('testimonials');
    }
};

Permissions are rows in the Spatie permissions table. Create them in a migration so they exist wherever the plugin is installed and can be added later in an upgrade. The extra columns title, group and description drive the Roles & permissions screen.

Modules/Testimonials/database/migrations/2026_10_21_000002_add_testimonials_permissions.php

<?php

declare(strict_types=1);

use App\Models\Permission;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
use Spatie\Permission\PermissionRegistrar;

return new class extends Migration
{
    /** @var array<string, string> permission name => description */
    private array $permissions = [
        'manage testimonials' => 'View the testimonials list and publish or unpublish entries.',
        'create testimonials' => 'Create testimonials.',
        'edit testimonials' => 'Edit testimonials.',
        'delete testimonials' => 'Delete testimonials.',
    ];

    public function up(): void
    {
        if (! Schema::hasTable(config('permission.table_names.permissions', 'permissions'))) {
            return;
        }

        foreach ($this->permissions as $name => $description) {
            $permission = Permission::firstOrCreate(['name' => $name, 'guard_name' => 'web']);
            $permission->update([
                'title' => ucfirst($name),
                'group' => 'testimonials',
                'description' => $description,
            ]);
        }

        app(PermissionRegistrar::class)->forgetCachedPermissions();
    }

    public function down(): void
    {
        if (! Schema::hasTable(config('permission.table_names.permissions', 'permissions'))) {
            return;
        }

        Permission::query()->whereIn('name', array_keys($this->permissions))->delete();

        app(PermissionRegistrar::class)->forgetCachedPermissions();
    }
};

Modules/Testimonials/app/Models/Testimonial.php

<?php

declare(strict_types=1);

namespace Modules\Testimonials\Models;

use App\Models\BaseModel;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Modules\Testimonials\Database\Factories\TestimonialFactory;

/**
 * A customer testimonial shown by the shortcode, the widget and the dashboard counter.
 *
 * Extending BaseModel gives free cache invalidation: creating, updating or deleting a row flushes the
 * response cache, so public pages that show testimonials never serve stale HTML.
 *
 * @property int $id
 * @property string $author_name
 * @property string|null $author_role
 * @property string|null $company
 * @property string $quote
 * @property int $rating
 * @property bool $is_published
 * @property int $sort_order
 */
class Testimonial extends BaseModel
{
    /** @var list<string> */
    protected $fillable = [
        'author_name',
        'author_role',
        'company',
        'quote',
        'rating',
        'is_published',
        'sort_order',
    ];

    /** @var array<string, string> */
    protected $casts = [
        'rating' => 'integer',
        'is_published' => 'boolean',
        'sort_order' => 'integer',
    ];

    protected static function newFactory(): TestimonialFactory
    {
        return TestimonialFactory::new();
    }

    /** Only entries visitors may see. */
    #[Scope]
    protected function published(Builder $query): void
    {
        $query->where('is_published', true);
    }

    /** Manual order first, newest first within the same position. */
    #[Scope]
    protected function ordered(Builder $query): void
    {
        $query->orderBy('sort_order')->orderByDesc('id');
    }
}

Why BaseModel? It boots the ClearCache trait: whenever a row is created, updated or deleted the response cache is flushed, so public pages containing your block never serve stale HTML. This is the cheapest way to make a plugin cache-correct (see Caching). The #[Scope] attribute methods give you Testimonial::query()->published()->ordered().

Modules/Testimonials/database/factories/TestimonialFactory.php

<?php

declare(strict_types=1);

namespace Modules\Testimonials\Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;
use Modules\Testimonials\Models\Testimonial;

/**
 * @extends Factory<Testimonial>
 */
class TestimonialFactory extends Factory
{
    protected $model = Testimonial::class;

    /** @return array<string, mixed> */
    public function definition(): array
    {
        return [
            'author_name' => fake()->name(),
            'author_role' => fake()->jobTitle(),
            'company' => fake()->company(),
            'quote' => fake()->paragraph(),
            'rating' => fake()->numberBetween(3, 5),
            'is_published' => true,
            'sort_order' => 0,
        ];
    }

    public function unpublished(): static
    {
        return $this->state(fn (): array => ['is_published' => false]);
    }
}

The factory namespace matches the Database\Factories mapping in composer.json; the model points to it through newFactory().

Step 3 — Provider, routes and breadcrumbs

The service provider is the single entry point the platform calls. It extends Nwidart\Modules\Support\ModuleServiceProvider, whose boot() already registers commands, schedules, config, views (namespace testimonials::), the Modules\Testimonials\View\Components component namespace and migrations. We add our own registrations on top.

Modules/Testimonials/app/Providers/TestimonialsServiceProvider.php

<?php

declare(strict_types=1);

namespace Modules\Testimonials\Providers;

use App\Events\AdminBooting;
use App\Facades\Dashboard;
use App\Facades\Shortcode;
use App\Facades\Widgets;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Event;
use Modules\Testimonials\Helpers\TestimonialsMenu;
use Modules\Testimonials\Helpers\TestimonialsSettings;
use Modules\Testimonials\Shortcodes\TestimonialsShortcode;
use Modules\Testimonials\View\Components\Testimonials;
use Modules\Testimonials\Widgets\Admin\TestimonialsCounterWidget;
use Modules\Testimonials\Widgets\Frontend\TestimonialsWidget;
use Nwidart\Modules\Support\ModuleServiceProvider;

class TestimonialsServiceProvider extends ModuleServiceProvider
{
    protected string $name = 'Testimonials';

    protected string $nameLower = 'testimonials';

    /** @var array<int, class-string> */
    protected array $commands = [];

    /** @var array<int, class-string> */
    protected array $providers = [
        RouteServiceProvider::class,
    ];

    /**
     * Runs on every request, before any other provider has booted: bindings and config only.
     */
    public function register(): void
    {
        parent::register();

        $this->mergeConfigFrom(module_path($this->name, 'config/config.php'), $this->nameLower);
    }

    /**
     * Runs on every request after all providers registered. Register everything that other parts of the
     * application look up: views, breadcrumbs, dashboard rows, shortcodes, widgets and admin hooks.
     */
    public function boot(): void
    {
        parent::boot();

        $this->registerTranslations();
        $this->registerBreadcrumbs();
        $this->registerDashboard();
        $this->registerPageBuilderParts();

        Widgets::register('testimonials-list', TestimonialsWidget::class);

        // Menus and settings are registered per admin request, after the core has registered its own.
        Event::listen(AdminBooting::class, function (): void {
            TestimonialsMenu::register();
            TestimonialsSettings::register();
        });
    }

    protected function registerDashboard(): void
    {
        Dashboard::admin()
            ->row('testimonials', order: 25, class: 'row g-4 mb-4', permission: 'manage testimonials')
            ->widget(
                id: 'testimonials.counter',
                widget: TestimonialsCounterWidget::class,
                async: false,
                order: 10,
                class: 'col-12 col-md-6 col-xl-4',
            );
    }

    protected function registerPageBuilderParts(): void
    {
        // The tag the editor writes into saved content is <x-shortcode-testimonials>.
        Blade::component('shortcode-testimonials', Testimonials::class);

        Shortcode::category('testimonials', __('testimonials::testimonials.builder_group'), 40);
        Shortcode::registerIfMissing(TestimonialsShortcode::class);
    }

    /**
     * ModuleServiceProvider's default registers the lang folder without a namespace, so only the plain
     * key (`testimonials.menu`) would resolve. The bundled modules override it to get the namespaced
     * key (`testimonials::testimonials.menu`).
     */
    protected function registerTranslations(): void
    {
        $langPath = lang_path('modules/' . $this->nameLower);

        if (is_dir($langPath)) {
            $this->loadTranslationsFrom($langPath, $this->nameLower);
            $this->loadJsonTranslationsFrom($langPath);

            return;
        }

        $moduleLangPath = module_path($this->name, 'lang');
        $this->loadTranslationsFrom($moduleLangPath, $this->nameLower);
        $this->loadJsonTranslationsFrom($moduleLangPath);
    }

    protected function registerBreadcrumbs(): void
    {
        $breadcrumbs = module_path($this->name, 'routes/breadcrumbs.php');

        if (file_exists($breadcrumbs)) {
            require $breadcrumbs;
        }
    }
}

Why registerTranslations() is overridden

The base implementation registers your lang/ folder without a namespace (so only __('testimonials.menu') would work). Every bundled module overrides it to get the namespaced form __('testimonials::testimonials.menu'). This was confirmed while building this example.

register() vs boot() — register() binds and merges config; boot() registers things other parts of the app look up (dashboard rows, shortcodes, widgets). Menus and settings are registered inside an AdminBooting listener because they are rebuilt on every admin request after the core has registered its own entries. Details: The service provider.

Modules/Testimonials/app/Providers/RouteServiceProvider.php

<?php

declare(strict_types=1);

namespace Modules\Testimonials\Providers;

use App\Http\Middleware\AdminTheme;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;

class RouteServiceProvider extends ServiceProvider
{
    protected string $name = 'Testimonials';

    public function map(): void
    {
        $this->mapAdminRoutes();
    }

    /**
     * Same middleware stack as the core admin routes (routes/admin.php), prefixed with the configurable admin URL.
     */
    protected function mapAdminRoutes(): void
    {
        Route::middleware(['web', 'auth', 'verified', '2fa', AdminTheme::class])
            ->as('admin.testimonials.')
            ->prefix(config('dotartisan.admin_prefix', 'admin') . '/' . config('testimonials.admin_prefix', 'testimonials'))
            ->group(module_path($this->name, 'routes/admin.php'));
    }
}

This is the same stack the core admin uses (routes/admin.php): auth, verified, 2fa and the AdminTheme middleware, which selects the admin theme and fires AdminBooting so the sidebar and settings are populated for your pages too. The route-name prefix admin.testimonials. matters: demo mode blocks write routes by name pattern (admin.*.store, admin.*.update, admin.*.destroy).

Modules/Testimonials/routes/admin.php

<?php

declare(strict_types=1);

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

// Loaded by RouteServiceProvider: names are prefixed with `admin.testimonials.`, URLs with `/{admin}/testimonials`.
Route::get('/', [TestimonialController::class, 'index'])->name('index')->can('manage testimonials');
Route::get('create', [TestimonialController::class, 'create'])->name('create')->can('create testimonials');
Route::post('/', [TestimonialController::class, 'store'])->name('store')->can('create testimonials');
Route::get('{testimonial}/edit', [TestimonialController::class, 'edit'])->name('edit')->can('edit testimonials');
Route::put('{testimonial}', [TestimonialController::class, 'update'])->name('update')->can('edit testimonials');
Route::post('{testimonial}/toggle', [TestimonialController::class, 'toggle'])->name('toggle')->can('manage testimonials');
Route::delete('{testimonial}', [TestimonialController::class, 'destroy'])->name('destroy')->can('delete testimonials');

Each route carries its own ->can('…'). Spatie permissions are resolved through the Laravel Gate, and the super admin role passes every check through Gate::before.

Modules/Testimonials/routes/breadcrumbs.php

<?php

declare(strict_types=1);

use Diglactic\Breadcrumbs\Breadcrumbs;
use Diglactic\Breadcrumbs\Generator as BreadcrumbTrail;
use Modules\Testimonials\Models\Testimonial;

// Every named admin route that renders a page needs a breadcrumb: the admin layout calls Breadcrumbs::render().
Breadcrumbs::for('admin.testimonials.index', function (BreadcrumbTrail $trail): void {
    $trail->parent('admin.dashboard');
    $trail->push(__('testimonials::testimonials.menu'), route('admin.testimonials.index'));
});

Breadcrumbs::for('admin.testimonials.create', function (BreadcrumbTrail $trail): void {
    $trail->parent('admin.testimonials.index');
    $trail->push(__('testimonials::testimonials.create'), route('admin.testimonials.create'));
});

Breadcrumbs::for('admin.testimonials.edit', function (BreadcrumbTrail $trail, Testimonial $testimonial): void {
    $trail->parent('admin.testimonials.index');
    $trail->push($testimonial->author_name, route('admin.testimonials.edit', $testimonial));
});

The admin layout calls Breadcrumbs::render() on every page, and the breadcrumb library throws for a named route without a definition. Define one for each route that renders a page (index, create, edit); routes that only redirect (store, update, destroy, toggle) do not need one.

Step 4 — Controller, form request and views

Modules/Testimonials/app/Http/Requests/TestimonialRequest.php

<?php

declare(strict_types=1);

namespace Modules\Testimonials\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class TestimonialRequest extends FormRequest
{
    /**
     * Authorisation is enforced on the route (`->can('create testimonials')`), not here.
     */
    public function authorize(): bool
    {
        return true;
    }

    /** @return array<string, array<int, string>> */
    public function rules(): array
    {
        return [
            'author_name' => ['required', 'string', 'max:150'],
            'author_role' => ['nullable', 'string', 'max:150'],
            'company' => ['nullable', 'string', 'max:150'],
            'quote' => ['required', 'string', 'max:2000'],
            'rating' => ['required', 'integer', 'between:1,5'],
            'sort_order' => ['nullable', 'integer', 'min:0', 'max:65535'],
            'is_published' => ['nullable', 'boolean'],
        ];
    }

    /**
     * @return array<string, mixed>
     */
    public function payload(): array
    {
        return [
            ...$this->safe()->except('is_published'),
            'sort_order' => (int) ($this->validated('sort_order') ?? 0),
            'is_published' => $this->boolean('is_published'),
        ];
    }
}

Modules/Testimonials/app/Http/Controllers/Admin/TestimonialController.php

<?php

declare(strict_types=1);

namespace Modules\Testimonials\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Modules\Testimonials\Http\Requests\TestimonialRequest;
use Modules\Testimonials\Models\Testimonial;

class TestimonialController extends Controller
{
    public function index(Request $request): View
    {
        $items = Testimonial::query()
            ->when(
                $request->filled('search'),
                fn ($query) => $query->where(function ($inner) use ($request): void {
                    $term = '%' . $request->string('search')->toString() . '%';
                    $inner->where('author_name', 'like', $term)
                        ->orWhere('company', 'like', $term)
                        ->orWhere('quote', 'like', $term);
                }),
            )
            ->ordered()
            ->paginate((int) setting('admin_pagination', 10))
            ->withQueryString();

        return view('testimonials::admin.index', ['items' => $items]);
    }

    public function create(): View
    {
        return view('testimonials::admin.form', ['item' => new Testimonial(['rating' => 5, 'is_published' => true])]);
    }

    public function store(TestimonialRequest $request): RedirectResponse
    {
        $item = Testimonial::create($request->payload());

        return redirect()
            ->route('admin.testimonials.edit', $item)
            ->with('success', __('testimonials::testimonials.saved'));
    }

    public function edit(Testimonial $testimonial): View
    {
        return view('testimonials::admin.form', ['item' => $testimonial]);
    }

    public function update(TestimonialRequest $request, Testimonial $testimonial): RedirectResponse
    {
        $testimonial->update($request->payload());

        return redirect()
            ->route('admin.testimonials.edit', $testimonial)
            ->with('success', __('testimonials::testimonials.saved'));
    }

    public function toggle(Testimonial $testimonial): RedirectResponse
    {
        $testimonial->update(['is_published' => ! $testimonial->is_published]);

        return back()->with('success', __('testimonials::testimonials.saved'));
    }

    public function destroy(Testimonial $testimonial): RedirectResponse
    {
        $testimonial->delete();

        return redirect()
            ->route('admin.testimonials.index')
            ->with('success', __('testimonials::testimonials.deleted'));
    }
}

Modules/Testimonials/resources/views/admin/index.blade.php

<x-admin-layout>
    <form class="row g-2 mb-3">
        <div class="col-md-4">
            <input type="search" name="search" value="{{ request('search') }}" class="form-control"
                placeholder="@lang('testimonials::testimonials.search')">
        </div>
        <div class="col-auto">
            <button class="btn btn-primary">@lang('testimonials::testimonials.filter')</button>
        </div>
    </form>

    <div class="card">
        <div class="card-header d-flex justify-content-between align-items-center">
            <strong>@lang('testimonials::testimonials.menu')</strong>
            @can('create testimonials')
                <a href="{{ route('admin.testimonials.create') }}" class="btn btn-primary btn-sm">
                    @lang('testimonials::testimonials.create')
                </a>
            @endcan
        </div>
        <div class="card-body p-0">
            <div class="table-responsive">
                <table class="table table-striped align-middle mb-0">
                    <thead>
                        <tr>
                            <th>@lang('testimonials::testimonials.fields.author_name')</th>
                            <th>@lang('testimonials::testimonials.fields.company')</th>
                            <th>@lang('testimonials::testimonials.fields.rating')</th>
                            <th>@lang('testimonials::testimonials.fields.status')</th>
                            <th class="text-end">@lang('testimonials::testimonials.actions')</th>
                        </tr>
                    </thead>
                    <tbody>
                        @forelse ($items as $item)
                            <tr>
                                <td>
                                    <strong>{{ $item->author_name }}</strong>
                                    <div class="small text-body-secondary">{{ \Illuminate\Support\Str::limit($item->quote, 80) }}</div>
                                </td>
                                <td>{{ $item->company }}</td>
                                <td>{{ $item->rating }}/5</td>
                                <td>
                                    <span class="badge {{ $item->is_published ? 'bg-success' : 'bg-secondary' }}">
                                        {{ $item->is_published ? __('testimonials::testimonials.status.published') : __('testimonials::testimonials.status.draft') }}
                                    </span>
                                </td>
                                <td class="text-end">
                                    @can('manage testimonials')
                                        <form method="POST" action="{{ route('admin.testimonials.toggle', $item) }}" class="d-inline">
                                            @csrf
                                            <button class="btn btn-sm btn-outline-secondary">
                                                {{ $item->is_published ? __('testimonials::testimonials.unpublish') : __('testimonials::testimonials.publish') }}
                                            </button>
                                        </form>
                                    @endcan
                                    @can('edit testimonials')
                                        <a class="btn btn-sm btn-primary" href="{{ route('admin.testimonials.edit', $item) }}">
                                            @lang('testimonials::testimonials.edit')
                                        </a>
                                    @endcan
                                    @can('delete testimonials')
                                        <form method="POST" action="{{ route('admin.testimonials.destroy', $item) }}" class="d-inline"
                                            data-confirm="{{ __('testimonials::testimonials.confirm_delete') }}"
                                            onsubmit="return confirm(this.dataset.confirm)">
                                            @csrf
                                            @method('DELETE')
                                            <button class="btn btn-sm btn-danger">@lang('testimonials::testimonials.delete')</button>
                                        </form>
                                    @endcan
                                </td>
                            </tr>
                        @empty
                            <tr>
                                <td colspan="5" class="text-center text-body-secondary py-4">
                                    @lang('testimonials::testimonials.empty')
                                </td>
                            </tr>
                        @endforelse
                    </tbody>
                </table>
            </div>
        </div>
        @if ($items->hasPages())
            <div class="card-footer">{{ $items->links() }}</div>
        @endif
    </div>
</x-admin-layout>

Modules/Testimonials/resources/views/admin/form.blade.php

<x-admin-layout>
    <form method="POST"
        action="{{ $item->exists ? route('admin.testimonials.update', $item) : route('admin.testimonials.store') }}">
        @csrf
        @if ($item->exists)
            @method('PUT')
        @endif

        <div class="card">
            <div class="card-header">
                <strong>{{ $item->exists ? __('testimonials::testimonials.edit') : __('testimonials::testimonials.create') }}</strong>
            </div>
            <div class="card-body">
                <div class="row g-3">
                    <div class="col-md-6">
                        <label class="form-label" for="author_name">@lang('testimonials::testimonials.fields.author_name')</label>
                        <input id="author_name" name="author_name" class="form-control @error('author_name') is-invalid @enderror"
                            value="{{ old('author_name', $item->author_name) }}" required maxlength="150">
                        @error('author_name')<div class="invalid-feedback">{{ $message }}</div>@enderror
                    </div>
                    <div class="col-md-3">
                        <label class="form-label" for="author_role">@lang('testimonials::testimonials.fields.author_role')</label>
                        <input id="author_role" name="author_role" class="form-control @error('author_role') is-invalid @enderror"
                            value="{{ old('author_role', $item->author_role) }}" maxlength="150">
                        @error('author_role')<div class="invalid-feedback">{{ $message }}</div>@enderror
                    </div>
                    <div class="col-md-3">
                        <label class="form-label" for="company">@lang('testimonials::testimonials.fields.company')</label>
                        <input id="company" name="company" class="form-control @error('company') is-invalid @enderror"
                            value="{{ old('company', $item->company) }}" maxlength="150">
                        @error('company')<div class="invalid-feedback">{{ $message }}</div>@enderror
                    </div>
                    <div class="col-12">
                        <label class="form-label" for="quote">@lang('testimonials::testimonials.fields.quote')</label>
                        <textarea id="quote" name="quote" rows="5" class="form-control @error('quote') is-invalid @enderror"
                            required maxlength="2000">{{ old('quote', $item->quote) }}</textarea>
                        @error('quote')<div class="invalid-feedback">{{ $message }}</div>@enderror
                    </div>
                    <div class="col-md-3">
                        <label class="form-label" for="rating">@lang('testimonials::testimonials.fields.rating')</label>
                        <select id="rating" name="rating" class="form-select @error('rating') is-invalid @enderror">
                            @foreach (range(5, 1) as $stars)
                                <option value="{{ $stars }}" @selected((int) old('rating', $item->rating) === $stars)>{{ $stars }}</option>
                            @endforeach
                        </select>
                        @error('rating')<div class="invalid-feedback">{{ $message }}</div>@enderror
                    </div>
                    <div class="col-md-3">
                        <label class="form-label" for="sort_order">@lang('testimonials::testimonials.fields.sort_order')</label>
                        <input id="sort_order" type="number" min="0" name="sort_order"
                            class="form-control @error('sort_order') is-invalid @enderror"
                            value="{{ old('sort_order', $item->sort_order ?? 0) }}">
                        @error('sort_order')<div class="invalid-feedback">{{ $message }}</div>@enderror
                    </div>
                    <div class="col-md-3 d-flex align-items-end">
                        <div class="form-check form-switch">
                            <input type="hidden" name="is_published" value="0">
                            <input class="form-check-input" type="checkbox" role="switch" id="is_published"
                                name="is_published" value="1" @checked(old('is_published', $item->is_published))>
                            <label class="form-check-label" for="is_published">@lang('testimonials::testimonials.fields.is_published')</label>
                        </div>
                    </div>
                </div>
            </div>
            <div class="card-footer d-flex justify-content-between">
                <a href="{{ route('admin.testimonials.index') }}" class="btn btn-outline-secondary">@lang('common.back')</a>
                <button class="btn btn-primary">@lang('common.saveChanges')</button>
            </div>
        </div>
    </form>
</x-admin-layout>

The views wrap their content in <x-admin-layout> (the admin theme's shell). @can(…) hides buttons the user may not use — the route still enforces the permission, so hiding is cosmetic. Language strings:

Modules/Testimonials/lang/en/testimonials.php

<?php

declare(strict_types=1);

return [
    'menu' => 'Testimonials',
    'all' => 'All testimonials',
    'create' => 'Add testimonial',
    'edit' => 'Edit testimonial',
    'saved' => 'Testimonial saved.',
    'deleted' => 'Testimonial deleted.',
    'builder_group' => 'Testimonials',
    'search' => 'Search by name, company or quote',
    'filter' => 'Filter',
    'empty' => 'No testimonials yet.',
    'actions' => 'Actions',
    'publish' => 'Publish',
    'unpublish' => 'Unpublish',
    'delete' => 'Delete',
    'confirm_delete' => 'Delete this testimonial?',
    'dashboard' => [
        'title' => 'Testimonials',
        'published' => ':published of :total published',
    ],
    'fields' => [
        'author_name' => 'Name',
        'author_role' => 'Role',
        'company' => 'Company',
        'quote' => 'Quote',
        'rating' => 'Rating',
        'sort_order' => 'Position',
        'is_published' => 'Published',
        'status' => 'Status',
    ],
    'status' => [
        'published' => 'Published',
        'draft' => 'Draft',
    ],
    'shortcode' => [
        'name' => 'Testimonials',
        'layout' => 'Layout',
        'layout_cards' => 'Cards',
        'layout_quote' => 'Large quote',
        'limit' => 'How many',
        'show_rating' => 'Show rating',
    ],
    'widget' => [
        'title' => 'Testimonials',
        'description' => 'Shows the latest published testimonials.',
        'limit' => 'How many testimonials',
    ],
    'settings' => [
        'title' => 'Testimonials',
        'general' => 'General',
        'general_title' => 'Testimonial defaults',
        'default_limit' => 'Default number of testimonials',
        'default_limit_help' => 'Used by the block when no number is set in the page builder.',
        'show_rating' => 'Show star rating',
        'show_rating_help' => 'Used by the block when the option is not set in the page builder.',
    ],
];

Step 5 — Sidebar menu

Modules/Testimonials/app/Helpers/TestimonialsMenu.php

<?php

declare(strict_types=1);

namespace Modules\Testimonials\Helpers;

use App\Facades\MenuManager;
use App\Services\Menu\MenuItems;
use Illuminate\Support\Facades\Auth;

class TestimonialsMenu
{
    /**
     * Adds the sidebar group. Only entries the current user may open are registered.
     */
    public static function register(): void
    {
        if (! self::can('manage testimonials')) {
            return;
        }

        MenuManager::make('testimonials', function (MenuItems $menu): void {
            $menu->label('testimonials::testimonials.menu')
                ->menuType(MenuItems::ADMIN)
                ->icon('nav-icon ph ph-quotes')
                ->route('admin.testimonials.index');
        }, 36);

        $group = MenuManager::get('testimonials');

        $group->subMenu('all', function (MenuItems $menu): void {
            $menu->key('all')
                ->label('testimonials::testimonials.all')
                ->menuType(MenuItems::ADMIN)
                ->icon('nav-icon ph ph-caret-right')
                ->route('admin.testimonials.index');
        });

        if (self::can('create testimonials')) {
            $group->subMenu('create', function (MenuItems $menu): void {
                $menu->key('create')
                    ->label('testimonials::testimonials.create')
                    ->menuType(MenuItems::ADMIN)
                    ->icon('nav-icon ph ph-caret-right')
                    ->route('admin.testimonials.create');
            });
        }
    }

    private static function can(string $permission): bool
    {
        return Auth::user()?->can($permission) ?? false;
    }
}

MenuManager::make($key, $callback, $priority) adds a top-level entry; subMenu() adds children. Labels are translation keys resolved when rendered. The icon is a Phosphor class. Priorities sort the sidebar (Pages and Posts are low numbers; the bundled commerce and booking groups use 30–34). The entry exists only for users who may use it — the same pattern the core and every bundled module follow. See Admin menu and dashboard.

Step 6 — A settings page

Modules/Testimonials/app/Helpers/TestimonialsSettings.php

<?php

declare(strict_types=1);

namespace Modules\Testimonials\Helpers;

use App\Facades\SettingsManager;
use Illuminate\Support\Facades\Auth;

class TestimonialsSettings
{
    /**
     * Registers /admin/settings/testimonials and its sidebar link under "Settings".
     *
     * The settings screen is only reachable with `manage settings`, so nothing is registered without it.
     */
    public static function register(): void
    {
        if (! (Auth::user()?->can('manage settings') ?? false)) {
            return;
        }

        SettingsManager::add([
            'id' => 'testimonials',
            'title' => __('testimonials::testimonials.settings.title'),
            'link' => [
                'id' => 'settings',
                'sub_menu' => [
                    'id' => 'testimonials-settings',
                    'label' => __('testimonials::testimonials.settings.title'),
                    'type' => 'testimonials',
                    'priority' => 40,
                ],
            ],
            // A closure: the field definitions are built only when the screen itself is opened.
            'pages' => fn (): array => [self::page()],
        ]);
    }

    /** @return array<string, mixed> */
    private static function page(): array
    {
        return [
            'id' => 'tab-testimonials',
            'name' => __('testimonials::testimonials.settings.general'),
            'title' => __('testimonials::testimonials.settings.general_title'),
            'fields' => [
                [
                    'type' => 'number',
                    'id' => 'testimonials_default_limit',
                    'name' => 'testimonials_default_limit',
                    'min' => '1',
                    'max' => (string) config('testimonials.max_limit', 24),
                    'step' => '1',
                    'label' => __('testimonials::testimonials.settings.default_limit'),
                    'helpTooltip' => __('testimonials::testimonials.settings.default_limit_help'),
                    'value' => setting('testimonials_default_limit', config('testimonials.default_limit', 6)),
                    'validation' => ['required', 'integer', 'between:1,' . config('testimonials.max_limit', 24)],
                ],
                [
                    'type' => 'switch',
                    'id' => 'testimonials_show_rating',
                    'name' => 'testimonials_show_rating',
                    'default' => 0,
                    'value' => 1,
                    'checked' => (bool) setting('testimonials_show_rating', true),
                    'label' => __('testimonials::testimonials.settings.show_rating'),
                    'helpTooltip' => __('testimonials::testimonials.settings.show_rating_help'),
                    'validation' => ['nullable', 'boolean'],
                ],
            ],
        ];
    }
}

The complete field catalogue (text, select, file, colour, font, spacing, conditional fields, .env and upload storage) is in Settings screens.

Step 7 — A dashboard widget

Modules/Testimonials/app/Widgets/Admin/TestimonialsCounterWidget.php

<?php

declare(strict_types=1);

namespace Modules\Testimonials\Widgets\Admin;

use Arrilot\Widgets\AbstractWidget;
use Illuminate\Contracts\View\View;
use Modules\Testimonials\Models\Testimonial;

/**
 * A card on the admin dashboard (registered with Dashboard::admin()->row(...)->widget(...)).
 */
class TestimonialsCounterWidget extends AbstractWidget
{
    /** @var array<string, mixed> */
    protected array $config = [];

    public function run(): View
    {
        return view('testimonials::widgets.admin.counter', [
            'total' => Testimonial::query()->count(),
            'published' => Testimonial::query()->published()->count(),
        ]);
    }
}

Modules/Testimonials/resources/views/widgets/admin/counter.blade.php

<div class="card h-100">
    <div class="card-body d-flex align-items-center gap-3">
        <span class="fs-1 text-primary"><i class="ph ph-quotes" aria-hidden="true"></i></span>
        <div>
            <div class="text-body-secondary small">@lang('testimonials::testimonials.dashboard.title')</div>
            <div class="fs-3 fw-semibold">{{ $total }}</div>
            <div class="small text-body-secondary">
                {{ __('testimonials::testimonials.dashboard.published', ['published' => $published, 'total' => $total]) }}
            </div>
        </div>
    </div>
</div>

The registration is in the provider (registerDashboard()): Dashboard::admin()->row('testimonials', order: 25, class: 'row g-4 mb-4', permission: 'manage testimonials')->widget(...). The dashboard page is just @dashboard('admin.dashboard'); it renders every registered row, skipping rows and items whose permission the user lacks. Dashboard widgets are plain Arrilot widgets.

Step 8 — A page-builder block (shortcode)

A shortcode has three parts: a definition that tells the editor how to show the block and its settings form, a Blade component that renders it on the public site, and a registration in the provider.

Modules/Testimonials/app/Shortcodes/TestimonialsShortcode.php

<?php

declare(strict_types=1);

namespace Modules\Testimonials\Shortcodes;

use App\Services\PageBuilder\Contracts\ShortcodeContract;
use App\Services\PageBuilder\ShortcodeDefinition;
use App\Services\PageBuilder\Traits\CheckboxTrait;
use App\Services\PageBuilder\Traits\NumberTrait;
use App\Services\PageBuilder\Traits\SelectTrait;

/**
 * The page-builder block. The editor stores <x-shortcode-testimonials layout="cards" limit="6"> in the page;
 * the Blade component of the same tag renders it with fresh data on every request.
 */
final class TestimonialsShortcode implements ShortcodeContract
{
    public static function id(): string
    {
        return 'testimonials';
    }

    public static function definition(): ShortcodeDefinition
    {
        return ShortcodeDefinition::make(
            id: self::id(),
            name: __('testimonials::testimonials.shortcode.name'),
            tag: 'x-shortcode-testimonials',
            category: 'testimonials',
            priority: 10,
        )
            ->icon('<i class="ph ph-quotes" aria-hidden="true"></i>')
            ->description('Displays published testimonials as cards or one large quote.')
            ->defaults(['layout' => 'cards'])
            ->ai(['enabled' => true, 'module' => 'testimonials'])
            ->traits([
                SelectTrait::make('layout', __('testimonials::testimonials.shortcode.layout'))
                    ->options([
                        ['id' => 'cards', 'name' => __('testimonials::testimonials.shortcode.layout_cards')],
                        ['id' => 'quote', 'name' => __('testimonials::testimonials.shortcode.layout_quote')],
                    ])
                    ->default('cards'),
                NumberTrait::make('limit', __('testimonials::testimonials.shortcode.limit'))
                    ->min(1)
                    ->max(24)
                    ->step(1),
                CheckboxTrait::make('show-rating', __('testimonials::testimonials.shortcode.show_rating'))
                    ->values('true', 'false'),
            ]);
    }
}

Modules/Testimonials/app/View/Components/Testimonials.php

<?php

declare(strict_types=1);

namespace Modules\Testimonials\View\Components;

use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\View\Component;
use Modules\Testimonials\Models\Testimonial;

/**
 * Renders <x-shortcode-testimonials>. Attributes arrive as strings from saved page content, so every value is
 * normalised in the constructor. An attribute left out falls back to the administrator's settings.
 */
class Testimonials extends Component
{
    public string $layout;

    public int $limit;

    public bool $showRating;

    public function __construct(
        string $layout = 'cards',
        mixed $limit = null,
        mixed $showRating = null,
    ) {
        $this->layout = in_array($layout, ['cards', 'quote'], true) ? $layout : 'cards';

        $max = (int) config('testimonials.max_limit', 24);
        $this->limit = max(1, min($max, (int) ($limit ?? setting('testimonials_default_limit', config('testimonials.default_limit', 6)))));

        $this->showRating = filter_var($showRating ?? setting('testimonials_show_rating', true), FILTER_VALIDATE_BOOLEAN);
    }

    /** @return Collection<int, Testimonial> */
    public function items(): Collection
    {
        return Testimonial::query()->published()->ordered()->limit($this->limit)->get();
    }

    public function render(): View
    {
        return view('testimonials::components.testimonials', ['items' => $this->items()]);
    }
}

Modules/Testimonials/resources/views/components/testimonials.blade.php

@if ($items->isEmpty())
    {{-- Nothing to show: render nothing on the public site, but keep the block visible while editing. --}}
@elseif ($layout === 'quote')
    @php($item = $items->first())
    <figure class="testimonials testimonials--quote text-center my-5 mx-auto" style="max-width: 46rem">
        @if ($showRating)
            <div class="text-warning mb-3" role="img" aria-label="{{ $item->rating }}/5">
                {{ str_repeat('★', $item->rating) }}{{ str_repeat('☆', 5 - $item->rating) }}
            </div>
        @endif
        <blockquote class="blockquote fs-3">&ldquo;{{ $item->quote }}&rdquo;</blockquote>
        <figcaption class="mt-3 text-body-secondary">
            <strong class="text-body">{{ $item->author_name }}</strong>
            @if ($item->author_role || $item->company)
                <span>&middot; {{ collect([$item->author_role, $item->company])->filter()->implode(', ') }}</span>
            @endif
        </figcaption>
    </figure>
@else
    <div class="testimonials testimonials--cards row g-4 my-4">
        @foreach ($items as $item)
            <div class="col-12 col-md-6 col-lg-4">
                <figure class="card h-100 mb-0">
                    <div class="card-body">
                        @if ($showRating)
                            <div class="text-warning mb-2" role="img" aria-label="{{ $item->rating }}/5">
                                {{ str_repeat('★', $item->rating) }}{{ str_repeat('☆', 5 - $item->rating) }}
                            </div>
                        @endif
                        <blockquote class="mb-3">&ldquo;{{ $item->quote }}&rdquo;</blockquote>
                        <figcaption class="small text-body-secondary">
                            <strong class="text-body">{{ $item->author_name }}</strong>
                            @if ($item->author_role || $item->company)
                                <br>{{ collect([$item->author_role, $item->company])->filter()->implode(', ') }}
                            @endif
                        </figcaption>
                    </div>
                </figure>
            </div>
        @endforeach
    </div>
@endif

More: Shortcodes, including assets, previews and dynamic option lists.

Step 9 — A front widget

Modules/Testimonials/app/Widgets/Frontend/TestimonialsWidget.php

<?php

declare(strict_types=1);

namespace Modules\Testimonials\Widgets\Frontend;

use App\Widgets\AbstractWidget;
use Illuminate\Contracts\View\View;
use Modules\Testimonials\Models\Testimonial;

/**
 * A sidebar/footer widget an administrator can drop into any widget area (Appearance -> Widgets).
 */
class TestimonialsWidget extends AbstractWidget
{
    /** @var array<string, mixed> */
    protected array $config = [
        'title' => false,
        'limit' => 3,
    ];

    public const TITLE = 'testimonials::testimonials.widget.title';

    public const DESCRIPTION = 'testimonials::testimonials.widget.description';

    /** View used on the public site. */
    public const VIEW = 'testimonials::widgets.frontend.list';

    /** Form shown to the administrator when the widget is placed in an area. */
    public const ADMIN_VIEW = 'testimonials::widgets.admin.list';

    /**
     * @param  array<string, mixed>  $config  ['title' => ..., 'settings' => object|array] as stored by the widget manager
     */
    public function __construct(array $config = [])
    {
        $settings = isset($config['settings']) ? (array) $config['settings'] : [];
        $settings['title'] = $config['title'] ?? '';

        parent::__construct($settings);
    }

    public function run(): View
    {
        $limit = max(1, min(12, (int) ($this->config['limit'] ?? 3)));

        return view(static::VIEW, [
            'title' => $this->config['title'] ?? false,
            'items' => Testimonial::query()->published()->ordered()->limit($limit)->get(),
        ]);
    }
}

Modules/Testimonials/resources/views/widgets/frontend/list.blade.php

@if ($items->isNotEmpty())
    <section class="widget widget-testimonials mb-4">
        @if (! empty($title))
            <h3 class="h5 mb-3">{{ $title }}</h3>
        @endif
        <ul class="list-unstyled mb-0">
            @foreach ($items as $item)
                <li class="mb-3">
                    <blockquote class="mb-1 small">&ldquo;{{ \Illuminate\Support\Str::limit($item->quote, 140) }}&rdquo;</blockquote>
                    <div class="small text-body-secondary">&mdash; {{ $item->author_name }}</div>
                </li>
            @endforeach
        </ul>
    </section>
@endif

Modules/Testimonials/resources/views/widgets/admin/list.blade.php

@extends('widgets.template')

@section('widget-form-' . $widget->id)
    <div class="mb-3">
        <label class="form-label" for="settings-limit-{{ $widget->id }}">
            {{ __('testimonials::testimonials.widget.limit') }}
        </label>
        <input type="number" min="1" max="12" class="form-control" id="settings-limit-{{ $widget->id }}"
            name="settings[limit]" value="{{ $widget->settings->limit ?? 3 }}">
    </div>
@endsection

Widgets::register('testimonials-list', TestimonialsWidget::class) (in the provider) makes the widget selectable in Appearance → Widgets. The admin editor view extends the admin theme's widgets.template and fills the widget-form-{id} section; whatever is posted as settings[...] is stored on the widget instance and arrives in the constructor as $config['settings']. See Widgets and widget areas.

Step 10 — Activate it

  1. Refresh Composer's autoload map
    composer dump-autoload

    A new module's classes are not autoloadable until Composer merges the module's composer.json. Nothing in the Plugin Manager runs Composer, so a plugin installed from a ZIP needs this step on the server (this is the most common reason a freshly installed plugin cannot be activated).

  2. Enable the module
    php artisan module:enable Testimonials     # or: Appearance → Plugins → Activate

    Either way, modules_statuses.json gets "Testimonials": true.

  3. Run the migrations
    php artisan migrate
    php artisan optimize:clear

    Activating a plugin does not migrate for you. This creates the table and the four permissions.

  4. Grant the permissions

    Open Users → Roles, edit a role and tick the testimonials group (the super admin passes every check automatically).

  5. Use it

    A Testimonials group appears in the sidebar, a card on the dashboard, a section under Settings, a Testimonials block in the page-builder's block panel and a widget in Appearance → Widgets.

Step 11 — Tests

Modules/Testimonials/tests/Feature/TestimonialsTest.php

<?php

declare(strict_types=1);

use App\Models\Page;
use Arrilot\Widgets\Facade as WidgetFacade;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Blade;
use Modules\Testimonials\Models\Testimonial;
use Modules\Testimonials\Widgets\Frontend\TestimonialsWidget;
use Tests\TestCase;

// tests/Pest.php only lists the bundled modules, so a plugin opts in here. Helpers such as
// userWithPermissions(), regularUser() and superAdmin() are defined in tests/Pest.php.
// The plugin must be active (modules_statuses.json) so RefreshDatabase runs its migrations.
uses(TestCase::class, RefreshDatabase::class);

it('hides the admin screens from users without the permission', function (): void {
    $this->actingAs(regularUser())
        ->get(route('admin.testimonials.index'))
        ->assertForbidden();
});

it('lists testimonials for a user who may manage them', function (): void {
    Testimonial::factory()->create(['author_name' => 'Ada Lovelace']);

    $this->actingAs(userWithPermissions(['manage testimonials']))
        ->get(route('admin.testimonials.index'))
        ->assertOk()
        ->assertSee('Ada Lovelace');
});

it('creates a testimonial and validates the input', function (): void {
    $user = userWithPermissions(['manage testimonials', 'create testimonials']);

    $this->actingAs($user)
        ->post(route('admin.testimonials.store'), ['author_name' => '', 'quote' => '', 'rating' => 9])
        ->assertSessionHasErrors(['author_name', 'quote', 'rating']);

    $this->actingAs($user)
        ->post(route('admin.testimonials.store'), [
            'author_name' => 'Grace Hopper',
            'quote' => 'It just works.',
            'rating' => 5,
            'is_published' => '1',
        ])
        ->assertRedirect();

    $saved = Testimonial::where('author_name', 'Grace Hopper')->first();

    expect($saved?->is_published)->toBeTrue()
        ->and($saved?->rating)->toBe(5);
});

it('separates create, edit and delete permissions', function (): void {
    $item = Testimonial::factory()->create();
    $viewer = userWithPermissions(['manage testimonials']);

    $this->actingAs($viewer)->get(route('admin.testimonials.create'))->assertForbidden();
    $this->actingAs($viewer)->get(route('admin.testimonials.edit', $item))->assertForbidden();
    $this->actingAs($viewer)->delete(route('admin.testimonials.destroy', $item))->assertForbidden();

    $this->actingAs(userWithPermissions(['delete testimonials']))
        ->delete(route('admin.testimonials.destroy', $item))
        ->assertRedirect(route('admin.testimonials.index'));

    expect(Testimonial::query()->count())->toBe(0);
});

it('lets a super admin toggle publication', function (): void {
    $item = Testimonial::factory()->create();

    $this->actingAs(superAdmin())
        ->post(route('admin.testimonials.toggle', $item))
        ->assertRedirect();

    expect($item->fresh()->is_published)->toBeFalse();
});

it('only renders published testimonials through the shortcode tag', function (): void {
    Testimonial::factory()->create(['author_name' => 'Visible Vera', 'sort_order' => 1]);
    Testimonial::factory()->unpublished()->create(['author_name' => 'Hidden Hank']);

    $html = Blade::render('<x-shortcode-testimonials layout="cards" limit="5" show-rating="false" />');

    expect($html)->toContain('Visible Vera')->not()->toContain('Hidden Hank');
});

it('is available to the page builder', function (): void {
    expect(app('shortcodes')->has('testimonials'))->toBeTrue();

    $definition = app('shortcodes')->get('testimonials');

    expect($definition['tag'])->toBe('x-shortcode-testimonials')
        ->and(collect($definition['traits'])->pluck('name')->all())->toBe(['layout', 'limit', 'show-rating']);
});

it('renders saved page content that contains the block', function (): void {
    Testimonial::factory()->create(['author_name' => 'Page Paula']);

    $page = Page::factory()->create(['published' => true]);
    $page->fill(['en' => ['title' => 'Kind words', 'content' => '<x-shortcode-testimonials layout="quote" />']])->save();

    $this->get($page->url)->assertOk()->assertSee('Page Paula');
});

// The menu and dashboard registries are singletons that live for the whole test, so each visitor gets its own test.
it('shows the dashboard counter and sidebar entry to users who manage testimonials', function (): void {
    Testimonial::factory()->count(3)->create();
    Testimonial::factory()->unpublished()->create();

    $this->actingAs(userWithPermissions(['manage dashboard', 'manage testimonials']))
        ->get(route('admin.dashboard'))
        ->assertOk()
        ->assertSee('3 of 4 published')
        ->assertSee(route('admin.testimonials.index'));
});

it('keeps the dashboard counter and sidebar entry from users who cannot manage testimonials', function (): void {
    Testimonial::factory()->count(3)->create();

    $this->actingAs(userWithPermissions(['manage dashboard']))
        ->get(route('admin.dashboard'))
        ->assertOk()
        ->assertDontSee('3 of 3 published')
        ->assertDontSee(route('admin.testimonials.index'));
});

it('registers a settings screen and stores its values', function (): void {
    $user = userWithPermissions(['manage settings']);

    $this->actingAs($user)
        ->get(route('admin.settings', ['type' => 'testimonials']))
        ->assertOk()
        ->assertSee('testimonials_default_limit');

    $this->actingAs($user)
        ->post(route('admin.settings.update', ['type' => 'testimonials']), [
            'testimonials_default_limit' => 2,
            'testimonials_show_rating' => '1',
        ])
        ->assertRedirect();

    expect((int) setting('testimonials_default_limit'))->toBe(2);

    Testimonial::factory()->count(4)->create();

    // No limit attribute on the tag: the saved default applies.
    expect(substr_count(Blade::render('<x-shortcode-testimonials />'), 'class="card h-100 mb-0"'))->toBe(2);
});

it('can be placed in a widget area', function (): void {
    Testimonial::factory()->create(['author_name' => 'Widget Wanda', 'quote' => 'Short and sweet.']);

    expect(app('SeoWidget')->get('testimonials-list'))->toBe(TestimonialsWidget::class);

    $html = (string) WidgetFacade::run(TestimonialsWidget::class, [
        'title' => 'Kind words',
        'settings' => ['limit' => 1],
    ]);

    expect($html)->toContain('Kind words')->toContain('Widget Wanda');
});

it('renders the create and edit forms and updates an entry', function (): void {
    $item = Testimonial::factory()->create(['author_name' => 'Edit Eve']);
    $user = userWithPermissions(['manage testimonials', 'create testimonials', 'edit testimonials']);

    $this->actingAs($user)->get(route('admin.testimonials.create'))->assertOk()->assertSee('Add testimonial');
    $this->actingAs($user)->get(route('admin.testimonials.edit', $item))->assertOk()->assertSee('Edit Eve');

    $this->actingAs($user)
        ->put(route('admin.testimonials.update', $item), [
            'author_name' => 'Edit Eve',
            'quote' => 'Updated quote.',
            'rating' => 4,
            'is_published' => '0',
        ])
        ->assertRedirect(route('admin.testimonials.edit', $item));

    expect($item->fresh()->quote)->toBe('Updated quote.')
        ->and($item->fresh()->is_published)->toBeFalse();
});
php artisan test --compact Modules/Testimonials
vendor/bin/pint Modules/Testimonials
vendor/bin/phpstan analyse Modules/Testimonials

More on tooling: Testing.

Step 12 — Package and ship

Zip the module folder (without tests/) and upload it in Appearance → Plugins → Add new; it is validated, installed inactive, and can then be activated. The example folder ships a ready-made testimonials-1.0.0.zip. See Package, install and update.

Common problems

SymptomCause and fix
__('testimonials::x.y') returns the keyOverride registerTranslations() in your provider (step 3).
Class not found right after copying the module inRun composer dump-autoload; the module's PSR-4 map is merged at dump time.
Fatal error: type of $config must be array in a widgetArrilot's AbstractWidget declares protected array $config; redeclare it typed.
Admin page throws a breadcrumb exceptionEvery named route that renders a page needs a Breadcrumbs::for() definition.
Menu/dashboard assertions leak between requests in a testThe registries are singletons; one visitor per test.
PHPStan: property not found on BaseModel after Model::query()->first()BaseModel's docblock types query() as Builder|BaseModel; use Model::where(...) or a typed variable in tests.
The settings switch can never be turned offGive the field 'default' => 0; an unchecked checkbox is simply absent from the request.
Widgets screen returns 403 for non-super-adminsThe core widgets controller checks view widgets/edit widgets/delete widgets, which the core permission seeder does not create (see Known quirks).

Where to go from here