Build Your First Plugin
We will build Notes: a plugin that adds a page to the customer dashboard where each customer keeps private notes. It uses a menu item, a route, a Blade view, a migration and a model, which is the same shape as the first-party plugins.
1. Create the folder and manifest
core/plugins/Notes/
├── plugin.json
├── NotesPlugin.php
├── Http/NotesController.php
├── Models/Note.php
├── views/index.blade.php
└── database/migrations/2026_10_10_000001_create_notes_table.php
plugin.json:
{
"id": "notes",
"name": "Notes",
"version": "1.0.0",
"description": "Private notes for each customer.",
"provider": "plugins\\Notes\\NotesPlugin",
"namespace": "plugins\\Notes\\",
"requires": { "helpnest": ">=1.0" },
"capabilities": ["user-menu", "routes"]
}
2. The migration
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('notes_items', function (Blueprint $table) {
$table->id();
$table->foreignId('client_id')->index();
$table->string('body', 1000);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('notes_items');
}
};
3. The model
<?php
namespace plugins\Notes\Models;
use Illuminate\Database\Eloquent\Model;
class Note extends Model
{
protected $table = 'notes_items';
protected $fillable = ['client_id', 'body'];
}
4. The controller
Every query is scoped to the signed-in customer.
<?php
namespace plugins\Notes\Http;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use plugins\Notes\Models\Note;
class NotesController extends Controller
{
public function index()
{
$client = acting_client() ?? abort(403);
return view('notes::index', ['notes' => Note::where('client_id', $client->id)->latest()->get()]);
}
public function store(Request $request)
{
$client = acting_client() ?? abort(403);
$data = $request->validate(['body' => ['required', 'string', 'max:1000']]);
Note::create(['client_id' => $client->id] + $data);
return back()->with('success', 'Note saved.');
}
public function destroy(int $id)
{
$client = acting_client() ?? abort(403);
Note::where('client_id', $client->id)->findOrFail($id)->delete();
return back();
}
}
5. The view
The first-party plugins extend the customer dashboard layout, so your page automatically gets the sidebar and header.
@extends('frontend.client-dashboard.layout.app')
@section('page-title'){{ __('Notes') }}@endsection
@section('content')
<div class="max-w-[760px] mx-auto flex flex-col gap-4">
@if (session('success'))<div class="surface border border-hn rad px-4 py-3 text-sm">{{ session('success') }}</div>@endif
<form method="POST" action="{{ route('plugin.notes.user.store') }}" class="surface border border-hn rad-card" style="padding:20px">
@csrf
<textarea name="body" maxlength="1000" required style="width:100%"></textarea>
<button type="submit">{{ __('Add note') }}</button>
</form>
@foreach ($notes as $note)
<div class="surface border border-hn rad-card" style="padding:16px">
{{ $note->body }}
<form method="POST" action="{{ route('plugin.notes.user.destroy', $note->id) }}">@csrf<button>{{ __('Delete') }}</button></form>
</div>
@endforeach
</div>
@endsection
Always escape output with {{ }}. Never print visitor- or customer-supplied text with {!! !!}.
6. The provider
<?php
namespace plugins\Notes;
use App\Plugins\Contracts\PluginContextContract;
use App\Plugins\Contracts\PluginContract;
use App\Plugins\Menu\MenuItem;
use App\Plugins\Menu\MenuPosition;
use plugins\Notes\Http\NotesController;
class NotesPlugin implements PluginContract
{
public function register(PluginContextContract $context): void
{
view()->addNamespace('notes', $context->path('views'));
}
public function boot(PluginContextContract $context): void
{
$context->menus()->add('user.dashboard.sidebar', new MenuItem(
id: 'notes.index',
label: 'Notes',
route: 'plugin.notes.user.index',
icon: 'las la-sticky-note',
position: MenuPosition::end(50),
));
$context->routes()->user(function ($r) {
$r->get('/', [NotesController::class, 'index'], 'index');
$r->post('notes', [NotesController::class, 'store'], 'store');
$r->post('notes/{id}/delete', [NotesController::class, 'destroy'], 'destroy');
});
}
public function activate(PluginContextContract $context): void {}
public function deactivate(PluginContextContract $context): void {}
public function uninstall(PluginContextContract $context): void
{
\Illuminate\Support\Facades\Schema::dropIfExists('notes_items');
}
}
7. Try it
php artisan plugins:discover
php artisan plugins:enable notes
Sign in to the customer dashboard. Notes appears in the sidebar under the Plugins group. Disable it with php artisan plugins:disable notes and the menu item and routes disappear, while the data stays.
Adding a scheduled command
To run something on a schedule (as Analytics Pro does for its weekly report), register a command and schedule it inside app()->booted():
use Illuminate\Console\Scheduling\Schedule;
$context->commands([\plugins\Notes\Console\PruneNotes::class]);
app()->booted(function () {
app(Schedule::class)->command('notes:prune')->dailyAt('03:00')->withoutOverlapping();
});
Checklist before you ship
- Scope every query by
client_idtaken fromacting_client(). - Validate all input. Escape all output.
- Never let a third-party call or hook callback throw into a visitor's request.
- Put slow work in a queued job.
- Prefix your table names with your plugin name.
- Clean up your tables in
uninstall().

