Extending AcelleMail Source Code

When the REST API + 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 project structure as it actually is (not what older docs say), and the discipline that keeps your customisations alive through Acelle upgrades.

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):

  1. Create a migration:
    sudo -u www-data php artisan make:migration add_external_crm_id_to_customers \
      --table=customers
    
  2. Add the column in the new migration file (up() adds, down() drops)
  3. 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:

  • Custom logic lives in your service provider, not App\Providers\AppServiceProvider
  • Custom Blade views live in resources/views/vendor/overrides/, not in resources/views/auth/
  • Custom routes live in your provider's boot(), not routes/web.php or routes/api.php
  • Custom config lives in your own files (e.g. config/acme.php), not core configs
  • Custom migrations live in database/migrations/ (they're additive; never modify Acelle's)
  • You have an OVERRIDES.md at project root listing every customisation + the Acelle version it was tested against
  • Customisations are tracked in git (commit your app/Providers/AcmeExtensionServiceProvider.php)
  • You re-test after every Acelle patch upgrade (patch-x.x.x.bin)

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

8 comentarios

4 comentarios

  1. tnovak.cz
    if you're on node, use the `crypto.timingsafeequal()` instead of `===` for signature compare. the article mentions it but it's worth emphasizing — this is a real cve pattern
  2. danrey.dev
    Built a Cloudflare Worker to bridge Shopify → AcelleMail webhooks last year. The signature verification pattern in this article is the same one I used. ~30 lines total.
  3. jmorrison.itop…
    What's the recommended retry policy for failed deliveries on the receiver side? We see ~1% transient failures and aren't sure if we should auto-retry or rely on AcelleMail's redelivery.
    1. admin
      Currently a manual step. There's a feature request tracking it on the repo if you want to +1.
    2. admin (editado)
      we're aware of the silent-bail-out on deleted customers — there's an open issue for it. Workaround for now: monitor the campaign:rerun log for absence of expected log lines, alert when silent for > 20 min.
    3. admin (editado)
      same answer as above for saas-tenant — works the same way per-tenant, with the caveat that the cron must be set per-customer (not just system-wide).
  4. lucas.bernard.…
    can webhook secrets be rotated without downtime, or is there an overlap mechanism? we rotate everything quarterly.
    1. admin
      Depends on your version. 5.x supports it natively; 4.x needs a config flag set in `.env`. We'll note this caveat in the article on the next pass.

More in Developer Guide