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.
| Capability | Extension point used | Step |
|---|---|---|
A testimonials table with a model, factory and scopes | Module migrations, BaseModel | 2 |
| Admin list / create / edit / delete / publish | Module routes, AdminTheme, ->can() | 3–4 |
| Four permissions | Permission migration | 2 |
| Sidebar entry | MenuManager on AdminBooting | 5 |
| Settings page under Settings | SettingsManager | 6 |
| Dashboard card | Dashboard registry + Arrilot widget | 7 |
| A block editors drag into pages | Shortcode registry + Blade component | 8 |
| A sidebar/footer widget | Widgets registry | 9 |
| Feature tests | Pest + project helpers | 11 |
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.phpAdminBooting.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).
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().
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.
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'));
}
}App\Http\Controllers\Controller. Authorisation lives on the route, so the form request's authorize() simply returns true.admin_pagination, like every core list.success session key; the admin layout's <x-application-alerts /> and <x-application-messages /> display them.view('testimonials::admin.index').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.',
],
];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.
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'],
],
],
];
}
}id) becomes the URL /admin/settings/testimonials. Its link adds a child under the Settings sidebar group. pages are the tabs; each has fields.pages as a closure means the field definitions are only built when the screen itself is opened, instead of on every admin request.group are stored in the database settings table under name, and read with setting('testimonials_default_limit'). A switch needs default => 0 so an unchecked box saves 0 (browsers omit unchecked boxes).manage settings, so we register it only for users who hold it. Saving flushes the response cache automatically.The complete field catalogue (text, select, file, colour, font, spacing, conditional fields, .env and upload storage) is in Settings screens.
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.
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">“{{ $item->quote }}”</blockquote>
<figcaption class="mt-3 text-body-secondary">
<strong class="text-body">{{ $item->author_name }}</strong>
@if ($item->author_role || $item->company)
<span>· {{ 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">“{{ $item->quote }}”</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<x-shortcode-testimonials layout="cards" limit="6" show-rating="true"></x-shortcode-testimonials> into the page. Attribute names with hyphens become camelCase constructor arguments (show-rating → $showRating); values arrive as strings, so the component normalises everything.setting('testimonials_default_limit')) — that is how settings and blocks cooperate.->ai(['enabled' => true, ...]) opts the block into the AI assistant's capability catalogue.Blade::component('shortcode-testimonials', Testimonials::class), the category with Shortcode::category() and the definition with Shortcode::registerIfMissing().resources/themes/echo/vendor/testimonials/components/testimonials.blade.php.More: Shortcodes, including assets, previews and dynamic option lists.
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">“{{ \Illuminate\Support\Str::limit($item->quote, 140) }}”</blockquote>
<div class="small text-body-secondary">— {{ $item->author_name }}</div>
</li>
@endforeach
</ul>
</section>
@endifModules/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>
@endsectionWidgets::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.
composer dump-autoloadA 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).
php artisan module:enable Testimonials # or: Appearance → Plugins → ActivateEither way, modules_statuses.json gets "Testimonials": true.
php artisan migrate
php artisan optimize:clearActivating a plugin does not migrate for you. This creates the table and the four permissions.
Open Users → Roles, edit a role and tick the testimonials group (the super admin passes every check automatically).
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.
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();
});tests/Pest.php lists the bundled modules' test folders, so a plugin opts in with uses(TestCase::class, RefreshDatabase::class) at the top of its own file.RefreshDatabase (which runs migrate:fresh on SQLite) also runs the plugin's migrations.userWithPermissions([...]), regularUser() and superAdmin() are project helpers; they create the permissions on the fly.php artisan test --compact Modules/Testimonials
vendor/bin/pint Modules/Testimonials
vendor/bin/phpstan analyse Modules/TestimonialsMore on tooling: Testing.
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.
| Symptom | Cause and fix |
|---|---|
__('testimonials::x.y') returns the key | Override registerTranslations() in your provider (step 3). |
| Class not found right after copying the module in | Run composer dump-autoload; the module's PSR-4 map is merged at dump time. |
Fatal error: type of $config must be array in a widget | Arrilot's AbstractWidget declares protected array $config; redeclare it typed. |
| Admin page throws a breadcrumb exception | Every named route that renders a page needs a Breadcrumbs::for() definition. |
| Menu/dashboard assertions leak between requests in a test | The 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 off | Give the field 'default' => 0; an unchecked checkbox is simply absent from the request. |
| Widgets screen returns 403 for non-super-admins | The core widgets controller checks view widgets/edit widgets/delete widgets, which the core permission seeder does not create (see Known quirks). |
lang/en/testimonials.php to lang/ar/ — see Localization. For translatable content, switch the model to astrotomic/laravel-translatable like Post and Page (Models, migrations and media).HasMedia / Spatie Media Library.HasSlug and a slug renderer: Page types and public URLs.