Self-hosted email marketing with full source code. Pay once, own forever. Get AcelleMail — $74 →

The canonical complex plugin. Walked end-to-end.

Every concept covered in the rest of these docs — the boot-and-load flow, the four hook patterns, layout REGISTRY hooks, plugin-isolated migrations, the indirect translation flow, the four lifecycle states, the testsuite-per-plugin pattern — gets exercised in storage/app/plugins/acelle/ai/. The plugin ships eight Eloquent models, fourteen migrations, eighteen locales, sixty-plus Blade templates, three universal anonymous components, every layout-injection hook, and a hundred-plus Pest tests in its own testsuite. This page is the reading recipe — what to look at first, what to look at last, and how to learn from it without getting lost in the production-grade plumbing.

Why this plugin is the canonical reference

Most plugins in production are small. A sending driver is one class plus a connection blade. A payment gateway is a service plus a redirect controller. A simple sidebar add-on is one Hook::add call. None of those exercise the whole plugin SDK — and small plugins are not the right reading exercise for an author trying to understand how big a plugin can responsibly grow.

acelle/ai exists at the other end of the spectrum. It is a self-contained AI subsystem: an agent chatbox, universal text-rewrite components, KB-grounded coach personas, and an admin observability dashboard. Activating the plugin in an Acelle install adds the entire AI surface without touching core code; deactivating removes it cleanly. The plugin uses every concept the rest of these docs cover, in production. Reading it is the fastest way to see how the patterns combine when the feature is non-trivial.

The file tree at a glance

From the plugin's own README:

FolderWhat it owns
src/AIHandler/AI runtime engine — engines, agent loop, tools, settings resolver, observability writer/reader, KB lookup, URL sanitiser.
src/Models/Eight Eloquent models for the audit substrate (AIConversation, AIMessage, AIRequest, AIToolCall, AIFeedback, AIRawBlob, AIDailyRollup, AIToolUndoRecord).
src/Controllers/Admin controllers (/rui/admin/ai-*) + public-API controllers (/api/v1/ai/*) + the plugin-landing PluginDashboardController.
src/Services/PluginStatusReport, AISettingsService, AutomationService, AIObservabilityPolicy, etc.
src/ServiceProvider.phpSole entry point — registers PSR-4, routes, views, lang files, hooks, middleware aliases, lifecycle listeners.
database/migrations/Fourteen audit-substrate migrations. Run on activate, rolled back on delete.
resources/views/Sixty-plus admin Blade templates + three universal anonymous components (<x-mc-ai-chatbox>, <x-mc-ai-rewrite>, <x-mc-ai-subject-ab-generator>) + chatbox / sparkle JS partials.
resources/assets/CSS (~14 files) + JS (~21 files) published to public/plugins/acelle/ai/ via vendor:publish --tag=plugin --force on plugin install.
resources/lang/Eighteen locales × nine language files = the AI module's full surface translated.
tests/One hundred-plus Pest tests (Feature + Unit) + the Acelle\Ai\Tests\PluginTestCase base class.
routes.phpPlugin routes (admin + public API + the plugin-landing dashboard at /plugins/acelle/ai/dashboard).
composer.jsonPlugin metadata; extra.setting-route points at PluginDashboardController@index so the admin Plugins page "Settings" button deep-links into the plugin's own dashboard.

None of those folders are bespoke. Every one maps directly to a section in the rest of these docs. Reading the plugin is a process of recognising the same pattern shipped at scale.

Eight Eloquent models — the audit substrate

The AI module's data layer is shaped around auditability: every conversation, every model invocation, every tool call, every user feedback, and every blob of raw provider output is captured for replay and observability. Eight models cover that substrate:

ModelTableWhat it represents
AIConversationai_conversationsOne row per multi-turn agent / support session. Carries customer + user FKs, task key, screen route, and rolled-up token / cost totals.
AIMessageai_messagesOne row per user / agent turn. Role, content JSON, FK to a tool-call, latency, model used.
AIRequestai_requestsOne row per upstream API call. Engine, prompt hash, latency, cost, error status. Bridges AIMessage to the actual HTTP traffic.
AIToolCallai_tool_callsFunction-call invocations spawned by an agent turn. Tool name, input/output JSON, source flag.
AIFeedbackai_feedbackThumbs-up/down + free-text feedback per message + per conversation.
AIRawBlobai_raw_blobsOriginal raw provider responses, kept for replay / audit. Separate table because the rollup table needs to stay small.
AIDailyRollupai_daily_rollupPer-day aggregate for the admin observability dashboard — token totals, cost, error rate. Pre-aggregated so the dashboard reads cheaply.
AIToolUndoRecordai_tool_undo_recordsTracks reversible tool actions for the "undo last" feature.

Three patterns from this list translate directly into other plugins. Splitting "raw provider responses" into a separate table from "rolled-up summary" lets the rollup table stay small enough to scan. Nullable FKs to customers and users let the same row work for authenticated and anonymous traffic. One-row-per-day rollups give the admin dashboard cheap reads without a heavy JOIN against the activity tables.

Fourteen migrations one-line each

The migration filenames in storage/app/plugins/acelle/ai/database/migrations/ tell their own story — additive over time, immediately reversible, never a destructive schema move:

FilenameWhat it does
2026_04_28_000001_create_ai_conversations_table.phpMulti-turn chat sessions — uid, customer_id FK, status enum, token / cost rollups
2026_04_28_000002_create_ai_messages_table.phpSingle user / agent turn — role, content JSON, tool-call FK, latency, model used
2026_04_28_000003_create_ai_requests_table.phpOne row per upstream API call — engine, prompt hash, latency, cost, error
2026_04_28_000004_create_ai_tool_calls_table.phpFunction-call invocations spawned by an agent turn — input / output JSON
2026_04_28_000005_create_ai_feedback_table.phpThumbs-up/down + free-text feedback per message + per conversation
2026_04_28_000006_create_ai_raw_blobs_table.phpOriginal raw provider responses, kept for replay / audit
2026_04_28_000007_create_ai_daily_rollup_table.phpPer-day aggregate for the admin dashboard — token totals, cost, error rate
2026_04_29_000001_add_client_message_id_to_ai_messages.phpCross-tab dedup column — additive, no defaults
2026_04_30_000002_add_source_to_ai_tool_calls.phpTracks whether a tool call came from agent vs support route
2026_05_02_180000_widen_ai_conversations_client_session_uid.phpULID / UUID width fix — column-altering migration, fully reversible
2026_05_02_200000_create_ai_tool_undo_records_table.phpTracks reversible tool actions for the "undo last" feature
2026_05_03_000001_add_url_sanitization_to_ai_requests.phpAdds a JSON column for sanitised URL telemetry
2026_05_04_000001_create_ai_settings_table.phpPlugin-level admin settings — kept separate from plugins.data so each row can be indexed

Read the migrations top-to-bottom and you have the schema evolution of the entire AI module. Every additive migration is a feature ship — the additive shape is what makes the plugin's delete_plugin_* rollback always work, even when an admin uninstalls after a year of feature accretion.

Every hook the plugin uses

The plugin's ServiceProvider exercises every hook pattern except FILTER. A grep for Hook::* against storage/app/plugins/acelle/ai/src/ServiceProvider.php:

REGISTRY (Hook::add) — six contributions

  • Nine add_translation_file entries at register() lines 175-197 — one per translation surface (rewrite, chatbox, prompts, wait, subject AB, settings, admin usage, audit, permissions). Each surface is a separately editable file in the admin Languages UI.
  • layout.head.assets at boot() line 688 — contributes chatbox CSS + JS to every page that extends the master app / admin layouts.
  • layout.body.before_close at boot() line 699 — contributes the chatbox bubble HTML and the sparkle popover before every page's </body>.
  • admin.sidebar.groups at boot() line 718 — adds the AI group with its three or four child links to the admin sidebar.

All six gate themselves with aiPluginAvailable() — a helper that ultimately resolves to Plugin::getByName('acelle/ai')->isActive(). Returning null when the plugin is gated off is the conventional way to disappear cleanly without unloading the service provider.

EVENT (Hook::on) — two lifecycle listeners

  • activate_plugin_acelle/ai at line 472 — runs artisan migrate against the plugin's migrations folder.
  • delete_plugin_acelle/ai at line 546 — accepts the $keepData flag, rolls back migrations when not set, preserves the audit tables when set.

BEHAVIOR (Hook::set) — one icon URL override

  • icon_url_acelle/ai at line 522 — overrides the per-plugin BEHAVIOR hook so the admin Plugins page renders the AI module's own icon instead of the host's default plugin.svg.

Together these are most of the plugin's hook surface. Reading ServiceProvider.php top-to-bottom is the fastest way to see how the patterns combine in production.

The chatbox UI surface

The plugin contributes three universal Blade components in resources/views/components/ — none of them require the host application to know they exist:

  • <x-mc-ai-chatbox> — the floating chatbox bubble that opens into a multi-turn agent conversation. Mounted via the layout.body.before_close REGISTRY hook so it appears on every app + admin page.
  • <x-mc-ai-rewrite> — universal "rewrite this text" affordance that can be dropped next to any textarea in the host. Plugin-namespaced, no central registration.
  • <x-mc-ai-subject-ab-generator> — generates A/B subject-line variants from a prompt. Used in the campaign editor.

These three components show the pattern for "plugin contributes UI to multiple host pages without forking each page": ship the component as an anonymous Blade component under your plugin's view namespace, register it through the layout REGISTRY hooks for global mounting, or have host pages opt-in by including it directly. Both patterns work; the AI plugin uses both.

Nine files × eighteen locales

The plugin's register() registers nine separate translation files. The Language::dump() machinery then materialises each into seventeen non-English locale runtime folders under storage/app/data/plugins/acelle/ai/lang/. The result on disk: 153 runtime translation files (9 files × 17 non-English locales + 9 English originals = 162 minus the 9 English masters = 153 dump-clones), each separately editable through the host's Languages admin UI.

The nine surface files (registered via the $aiLangFiles loop in ServiceProvider::register()):

  • ai_rewrite — the universal text rewrite component
  • ai_chatbox — the chatbox UI
  • ai_chatbox_prompts — pre-canned prompts shown in the chatbox
  • ai_chatbox_wait — the smart-wait UI ("looking up… running tool… composing reply")
  • ai_subject_ab — the subject A/B generator
  • ai_settings — admin settings page labels
  • admin_ai_usage — admin usage / cost dashboard
  • admin_ai_audit — admin audit / replay UI
  • admin_ai_permissions — admin per-feature permission toggles

This split is the practical answer to "how do I keep translation files small enough that a translator can edit one in a single sitting": one master per logical surface, registered separately, materialised per locale, edited independently through the admin UI.

Plugin-owned config files

The plugin owns two config files under config/ — registered in ServiceProvider::boot() via $this->mergeConfigFrom() and reachable through the standard config('ai.*') and config('ai-navigation-hints.*') helpers. Plugin-owned config is the right place for static metadata that does not change per-install (engine catalog, prompt templates, navigation defaults); admin-editable settings live in the ai_settings table seeded by migration.

The split — config for plugin-shipped immutable defaults, DB rows for admin-editable mutable settings — is a pattern that ports cleanly to other plugins. Putting both in the plugins.data JSON column is tempting but punishes admin UI performance; a dedicated indexed table was the right call.

The test infrastructure

The plugin's test directory follows the host's tests/ shape — Unit + Feature folders plus a PluginTestCase base class at the root:

storage/app/plugins/acelle/ai/tests/
├── PluginTestCase.php          ← seeds the plugin row as active before every test
├── Feature/
│   ├── AIHandler/              ← engines, agent loop, tools, observability writer
│   └── PluginLifecycle/        ← lifecycle integration tests
├── Unit/                       ← isolated unit tests (no Laravel boot for some)
├── Fixtures/                   ← test fixtures + factories
├── Snapshots/                  ← Pest snapshot artefacts
└── Support/                    ← test-only helpers

The host's phpunit.xml registers the plugin's testsuite as <testsuite name="Plugin: acelle/ai">. ./vendor/bin/pest --testsuite="Plugin: acelle/ai" runs the full suite in parallel with the host's own Unit + Feature suites.

The PluginTestCase at the root demonstrates the per-request gate-cache reset trap that every plugin shipping middleware should adopt — see the Testing § gate-cache trap for the full pattern. Without it, the second test in the suite onward observes stale cache state from the first test's boot.

How to learn from it

Reading every line of a hundred-thousand-token plugin is not the right exercise. A four-step recipe is more useful:

  1. Clone or symlink the plugin into a host install. Activate it. Open the admin sidebar — the AI group should appear. Click "Settings" on the admin Plugins page entry — the plugin-landing dashboard should load. This proves the plugin's UI surface is wired into your local host.
  2. Read src/ServiceProvider.php top-to-bottom. Forty minutes. Every concept covered in Plugin architecture + Hook system + UI injection + Translations appears in this one file at production scale. Cross-reference with the deep-dive pages as you go.
  3. Trace one feature end-to-end. Pick the chatbox bubble. Find the entry point (layout.body.before_close hook in ServiceProvider), follow the partial it returns (ai::partials.body_assets), find the anonymous component that renders the bubble, find the JS that mounts it, find the API route the JS calls, find the controller, follow it down to the AIHandler runtime engine, watch the AIConversation + AIMessage + AIRequest rows get inserted. Two-to-three hours, end-to-end. After this you will know which patterns the AI plugin uses and which it does without.
  4. Adapt one pattern to your own plugin. Pick the smallest one that maps — usually the per-translation-surface registration loop, or the admin sidebar group, or the per-day rollup-table approach. Strip out the AI-specific code; keep the structural pattern. That is the plugin author's day-one productivity gain.

Where to go next

This is the last of the eleven developer deep-dives. From here, the index hub is the natural recap: Documentation index shows every page in the cluster organised into Foundation / Building / Quality / Reference. The developer landing is the marketing-shaped entry point for new visitors arriving from search.

Two practical next steps when you are ready to ship a real plugin: the activate → test → delete cycle from the Testing deep-dive proves the plugin's delete_plugin_* hook listener cleans up correctly. Plugin architecture § Recovery from broken state is the page to bookmark for production runtime issues — three failure modes plus the exact fix path each.

Beyond AcelleMail-specific patterns, the broader Laravel ecosystem applies. Plugin code is vanilla Laravel; the host runtime loader is the only non-standard piece. Anything you know about Eloquent, Blade, Pest, queues, scheduling, or middleware works inside the plugin folder unchanged.