Skip to content

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

9 comments

5 comments

  1. Daniel
    Can webhook secrets be rotated without downtime, or is there an overlap mechanism? We rotate everything quarterly.
    1. Admin
      No overlap window, it's a single secret on the sending server config, so rotation bounces in-flight callbacks until the vendor retries. Override parseWebhook to accept a list of secrets if you need old-and-new in parallel.
  2. Lucas
    Webhook signature verification is the kind of thing most docs hand-wave. The constant-time-compare warning is genuinely important — I've seen production codebases with == on signatures.
    1. Admin
      The `==` case is bad, but the one that bites more often is `===` on the raw strings, because it looks careful and still leaks timing. In PHP the fix is one function, `hash_equals($expected, $provided)`, and there's no reason to write the comparison any other way. The other half people skip: verify against the raw request body, not a re-encoded array. Once you've run the payload through `json_decode` and back, key order and unicode escaping can shift and the signature stops matching for reasons that look like a bug in the sender. I'll add a short note on the raw-body point to that section of the article, it's currently implied rather than stated.
  3. Tomas
    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
  4. Minh
    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
      Rely on your own retry, not on ours. Redelivery isn't a guarantee we make on the hook/webhook path, so if your receiver 500s or times out, assume the event is gone. Return 2xx as soon as you've persisted the payload, then process it out of band from a queue where you control the backoff. At ~1% transient failures, a small exponential backoff with 3-4 attempts and a dead-letter table is plenty, and it makes the failures visible instead of just quiet. One thing to build in while you're at it: dedupe on the message id, since anything that does retry can hand you the same event twice.
  5. James
    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...
    1. Admin
      Right, and the article buries it in a sentence when it should be the whole point of that section. Worth adding the gotcha too: `crypto.timingSafeEqual()` throws if the two buffers differ in length, so a malformed or truncated signature header will blow up your handler instead of returning a clean 401. Length-check first, then compare, and hash both sides to a fixed width if you want to skip that branch entirely. I'll rewrite the signature-verification bit with a working Node snippet. If you'd rather write it, send a PR and I'll merge it.

More in Developer Guide