What this is for#
When the REST API and webhooks aren't enough, AcelleMail's open Laravel codebase lets you extend it directly — add custom fields, integrate a custom sending provider, listen to internal events, override Blade views.
This guide walks the upgrade-safe extension patterns. The discipline matters: AcelleMail ships patches frequently, and direct edits to core files get wiped on upgrade.
Prerequisite: comfortable with Laravel (~mid-level PHP). If you've never touched a Laravel service provider, start with laravel.com/docs/providers.
Project structure (as it actually is in 2026)#
Some older docs reference app/Models/ (plural) — that's wrong for Acelle. The real layout:
/var/www/acellemail/
├── app/
│ ├── Model/ ← Eloquent models (SINGULAR)
│ │ ├── Customer.php
│ │ ├── Subscriber.php
│ │ ├── Campaign.php
│ │ ├── MailList.php
│ │ ├── Plan.php / Subscription.php
│ │ └── ...
│ ├── Models/ ← (Sometimes present for newer Laravel-style models)
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── Api/ ← REST API controllers
│ │ │ ├── Admin/ ← Admin UI controllers
│ │ │ └── Refactor/ ← Newer-architecture controllers
│ │ └── Middleware/
│ ├── Jobs/ ← Queue jobs (SendEmail, ProcessImport, ...)
│ ├── Services/ ← Business logic services
│ ├── Providers/ ← Laravel service providers (where you'll register listeners)
│ ├── Console/
│ │ └── Commands/ ← Artisan commands
│ ├── SendingServers/ ← Sending-provider drivers (SES, Mailgun, SendGrid, ...)
│ └── Cashier/ ← SaaS billing layer
├── config/
│ ├── webhook_events.php ← Webhook event catalog
│ ├── mail_drivers.php / sending_drivers.php
│ └── ...
├── routes/
│ ├── api.php ← REST API routes
│ ├── web.php ← Web routes (legacy)
│ ├── refactor.php ← Newer-architecture web routes
│ └── console.php ← Scheduled-command definitions
├── resources/
│ ├── views/ ← Blade templates
│ └── lang/ ← Localised strings
└── database/
├── migrations/
└── seeders/
Watch out: the app/Http/Controllers/ tree has TWO controller styles — Admin/ (older) and Refactor/Admin/ (newer). When extending, check both before editing — your route might be handled in either, depending on Acelle version.
Pattern 1 — Custom service provider (the foundation)#
Every extension starts with a custom service provider. This is your anchor — all listeners, view overrides, and bindings live here so they survive upgrades.
cd /var/www/acellemail
sudo -u www-data php artisan make:provider AcmeExtensionServiceProvider
Acelle's composer.json auto-discovers service providers in app/Providers/ — your new provider is registered automatically.
In app/Providers/AcmeExtensionServiceProvider.php:
<?php
namespace Acelle\Landing\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Event;
class AcmeExtensionServiceProvider extends ServiceProvider
{
public function register(): void
{
// Register bindings, custom config — runs early in app lifecycle
}
public function boot(): void
{
// Register listeners + view paths — runs after all providers registered
// Example: register a listener for an Acelle event
Event::listen(\App\Events\CampaignSent::class, function ($event) {
// Your custom logic
\Log::info('Custom hook: campaign sent', [
'campaign_id' => $event->campaign->id,
'recipients' => $event->campaign->recipients_count,
]);
});
// Example: add a view override path (see Pattern 2)
$this->loadViewsFrom(
resource_path('views/vendor/overrides'),
'overrides'
);
}
}
Why a separate provider, not editing AppServiceProvider? AppServiceProvider is part of Acelle core and gets overwritten by upgrades. Your provider in app/Providers/AcmeExtensionServiceProvider.php (custom namespace) won't be touched.
Pattern 2 — Blade view overrides#
Already covered in detail in the white-label customization guide. Summary:
# Copy the view you want to override
mkdir -p resources/views/vendor/overrides
cp resources/views/auth/login.blade.php \
resources/views/vendor/overrides/auth-login.blade.php
# Edit the copy
Then in your service provider, point the affected route at the override:
Route::get('/login', fn () => view('overrides::auth-login'));
Document every override in an OVERRIDES.md at the project root.
Pattern 3 — Custom sending-provider driver#
If you have your own SMTP gateway / API and want it as an option in Admin → Sending Servers, implement the driver interface.
Acelle's sending-server drivers live in app/SendingServers/Drivers/. Each driver implements the contract that defines send($email), verify() (auth check), getQuota() (remaining sends).
<?php
// app/SendingServers/Drivers/AcmeMailerDriver.php
namespace Acelle\Landing\SendingServers\Drivers;
use App\SendingServers\Drivers\AbstractDriver;
use App\SendingServers\Contracts\Drivable;
class AcmeMailerDriver extends AbstractDriver implements Drivable
{
public function send($email): array
{
// Use Guzzle / your SDK to POST to your gateway
$response = $this->client->post('https://api.acme-mailer.com/send', [
'json' => [
'from' => $email->from,
'to' => $email->to,
'subject' => $email->subject,
'html' => $email->html,
],
]);
return [
'status' => 'sent',
'message_id' => json_decode($response->getBody(), true)['id'],
];
}
public function verify(): bool
{
// Check credentials by hitting an auth-protected endpoint
return $this->client->get('https://api.acme-mailer.com/me')->getStatusCode() === 200;
}
public function getQuota(): array
{
// Return remaining sends if your gateway exposes it
return ['max_24h' => 50000, 'sent_24h' => 12000];
}
}
Register in your service provider:
public function register(): void
{
$this->app->extend('sending-server-driver-registry', function ($registry) {
$registry->register('acme-mailer', AcmeMailerDriver::class);
return $registry;
});
}
The driver now appears as an option when creating a new sending server in admin. Configuration fields (API key, region, etc.) are declared in a sibling AcmeMailerConfigSchema.php.
Exact registry method names may differ slightly across Acelle versions — check app/SendingServers/ for the current factory pattern. Look at the SES driver (SesDriver) as your reference.
Pattern 4 — Listening to events#
Acelle dispatches Laravel events for major operations. Hook them via Event::listen() in your service provider. Some commonly-used events:
| Event |
Fires when |
App\Events\CampaignSent |
A campaign finishes sending |
App\Events\CampaignStarted |
A campaign starts sending |
App\Events\SubscriberSubscribed |
A new subscriber confirms |
App\Events\SubscriberUnsubscribed |
A subscriber unsubscribes |
App\Events\BounceReceived |
A bounce is processed |
App\Events\ComplaintReceived |
A spam complaint is processed |
(Exact event class names vary across Acelle versions — grep -r 'event(new\|broadcast(new\|Event::dispatch' app/ to discover the full list in your install.)
Example: post to Slack on every campaign send:
Event::listen(\App\Events\CampaignSent::class, function ($event) {
$campaign = $event->campaign;
\Http::post(config('services.slack.webhook'), [
'text' => "Campaign sent: *{$campaign->name}* to {$campaign->recipients_count} recipients",
]);
});
Async listeners for heavy work — implement ShouldQueue:
class SlackNotifyOnCampaignSent implements \Illuminate\Contracts\Queue\ShouldQueue
{
public function handle(\App\Events\CampaignSent $event): void
{
// Heavy work — runs on the queue, not blocking the send
}
}
Event::listen(\App\Events\CampaignSent::class, SlackNotifyOnCampaignSent::class);
Pattern 5 — Adding custom fields to models#
If you need to add a column (e.g. external_crm_id on customers):
- Create a migration:
sudo -u www-data php artisan make:migration add_external_crm_id_to_customers \
--table=customers
- Add the column in the new migration file (
up() adds, down() drops)
- Add to the model's
$fillable — but don't edit app/Model/Customer.php directly (gets overwritten on upgrade). Instead, extend the model and have your service provider rebind it. This is fragile across upgrades; alternative is to put migrations in a separate provider and use Customer::saveQuietly() from a listener to set the field.
For simple cases (no rebinding needed), the migration + direct read via Customer::find($id)->external_crm_id works without model changes — Eloquent dynamically exposes any column.
Pragmatic alternative: add a meta JSON column once, then store any custom field as a key under it. Single migration; never needs another schema change.
Pattern 6 — Custom REST API routes#
Add your own API endpoints by defining routes in your service provider's boot():
public function boot(): void
{
\Route::middleware(['auth:api'])->prefix('api/v1/acme')->group(function () {
\Route::get('hello', function (\Illuminate\Http\Request $request) {
return ['greeting' => 'Hello ' . $request->user()->email];
});
\Route::post('crm-sync/{customer_uid}', \App\Acme\CrmSyncController::class);
});
}
Now GET /api/v1/acme/hello?api_token=... works. Route registration is upgrade-safe because it lives in your provider, not core route files.
Upgrade-safe checklist#
Before any extension, verify:
After an Acelle upgrade#
# 1. Before applying the upgrade, ensure you have a fresh backup
sudo /usr/local/bin/acellemail-db-backup.sh
# 2. Apply the upgrade per the standard flow
# (see your install guide's patch-upgrade section)
# 3. After upgrade, verify your extensions still work
cd /var/www/acellemail
sudo -u www-data php artisan tinker --execute='
echo "Provider loaded: " . (class_exists(\App\Providers\AcmeExtensionServiceProvider::class) ? "yes" : "no") . PHP_EOL;
echo "Custom route present: " . (\Route::has("api.acme.hello") ? "yes" : "no") . PHP_EOL;
'
# 4. Test each customisation manually against the new Acelle version
# 5. Update OVERRIDES.md with the new Acelle version you tested against
If something broke:
- Check Acelle's release notes for breaking changes
- Diff the affected core file (e.g.
app/Model/Customer.php) between versions
- Adjust your extension; commit; document in OVERRIDES.md
Common issues#
| What you see |
Likely cause |
Fix |
| Service provider not loading |
composer.json auto-discover skipped it |
composer dump-autoload; verify the file is in app/Providers/ |
| Event listener not firing |
Wrong event class name |
Verify exact class with Event::listen(\App\Events\CampaignSent::class, fn ($e) => \Log::debug("fired")) |
| Blade override not used |
View namespace not registered |
php artisan view:cache after registering loadViewsFrom |
| Custom route 404s |
Provider's boot() runs too late, or route conflicts |
Verify route order; use Route::has('name') to confirm registration |
| Sending-server driver doesn't appear in admin |
Registry not extended correctly |
Check the exact factory method name in your Acelle version; look at SES driver as reference |
php artisan errors after upgrade |
Cached config / views referencing removed classes |
php artisan optimize:clear |
| Listener fires twice |
Event re-emitted in newer Acelle version, or you registered listener twice |
Use Event::hasListeners() to debug; deduplicate registrations |
| Custom field on Customer model not saving |
Model has $guarded = [] or $fillable doesn't include your field |
Use Customer::find($id)->forceFill(['your_field' => $val])->save() if model rules block it |
FAQ#
Should I fork AcelleMail to git? For non-trivial customisations, yes. Maintain a private fork with your extensions on a branch (e.g. acme-extensions). Merge upstream upgrades into your branch; resolve conflicts each release.
Can I write a plugin without forking? Yes — service providers + Blade overrides + migrations don't require forking. The app/Providers/AcmeExtensionServiceProvider.php + resources/views/vendor/overrides/ + your custom config files can all sit alongside the stock install and survive upgrades.
What about Composer packages? You can publish your extensions as a Composer package and composer require them. Standard Laravel package layout (src/, config/, migrations/, views/). Plus side: clean isolation. Minus: requires running composer on the production server.
How do I add a custom admin menu item? Currently no first-class plugin API for menu items — you'd need to override the admin layout Blade. Acelle's roadmap mentions a plugin-API revamp; check acellemail-updates for current state.
Can I add a new model + table? Yes — php artisan make:model AcmeCustomerNote -m, define the migration + relationships, register in your provider. Use a namespace prefix to avoid conflicts (App\Acme\Models\CustomerNote).
Is there a Slack/Discord for AcelleMail developers? Check the Community Spotlight article for current community links.
How does plugin installation via /api/v1/plugins/install relate? That's for AcelleMail's first-class plugin format (a packaged plugin zip with a manifest.json). For custom in-place extensions of your install, you don't need the plugin system — the service provider pattern is simpler.
Related articles#