Xgenious/ docs

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_id taken from acting_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().
Still stuck?
Our support team is ready to help you get set up.
Get support