# 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.

- Canonical URL: https://acellesend.com/kb/articles/extending-acellemail-source-code
- Author: Tom Becker, Developer Editor
- Category: Developer Guide — REST API, webhooks, customization, extending source code, plugins
- Content type: guide · Difficulty: advanced
- Tags: Acellemail
- Published: 2026-06-28 · Last updated: 2026-08-15
- Reading time: 10 min
- Publisher: AcelleMail Knowledge Base, https://acellesend.com/kb

---

## What this is for

When the [REST API](https://acellesend.com/kb/articles/rest-api-authentication-and-endpoints) and [webhooks](https://acellesend.com/kb/articles/webhook-events-reference) 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](https://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.

```bash
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
<?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](https://acellesend.com/kb/articles/white-label-customization-guide#step-5--upgrade-safe-blade-overrides). Summary:

```bash
# 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:

```php
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
<?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:

```php
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:

```php
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`:

```php
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:
   ```bash
   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()`:

```php
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

```bash
# 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](https://acellesend.com/kb/category/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](https://acellesend.com/kb/articles/community-spotlight-business-case-studies) 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

- [Getting Started with the AcelleMail REST API](https://acellesend.com/kb/articles/getting-started-with-the-acellemail-rest-api) — use the API first; extend only when you can't
- [REST API Authentication and Endpoints](https://acellesend.com/kb/articles/rest-api-authentication-and-endpoints) — full endpoint reference
- [Webhook Events Reference](https://acellesend.com/kb/articles/webhook-events-reference) — outbound events you can subscribe to (alternative to listeners)
- [White-Label Customization Guide](https://acellesend.com/kb/articles/white-label-customization-guide) — Blade overrides for branding
- [Setting Up AcelleMail as a SaaS Platform](https://acellesend.com/kb/articles/setting-up-acellemail-as-a-saas-platform) — common SaaS extensions
- [Setting Up Queue Workers and Cron Jobs](https://acellesend.com/kb/articles/setting-up-queue-workers-and-cron-jobs) — queued listeners share this infra
- [Post-Install Hardening Checklist](https://acellesend.com/kb/articles/post-install-hardening-checklist) — apply after each upgrade
- [Configuring Amazon SES with AcelleMail](https://acellesend.com/kb/articles/configuring-amazon-ses-with-acellemail) — the SES driver is the reference for Pattern 3
- [Contributing to AcelleMail Development](https://acellesend.com/kb/articles/contributing-to-acellemail-development) — upstream contributions

---

## Related articles

- [Webhook Events Reference](https://acellesend.com/kb/articles/webhook-events-reference): AcelleMail has three distinct webhook systems — customer-lifecycle webhooks (SaaS billing events), per-campaign webhooks (open/click/bounce on a specific campaign), and inbound webhooks from sending providers (SES SNS, Mailgun events). This reference documents each, with the real event names from the Acelle source — not the made-up ones some older guides list.
- [Getting Started with the AcelleMail REST API](https://acellesend.com/kb/articles/getting-started-with-the-acellemail-rest-api): AcelleMail ships a REST API that exposes nearly every operation the admin UI offers — create lists, add subscribers, send campaigns, fire automations, read reports. This guide walks the 4 things you need to make your first successful API call: where to find your token, how to send it, what to call first, and how to debug when it doesn't work.
- [REST API Authentication and Endpoints](https://acellesend.com/kb/articles/rest-api-authentication-and-endpoints): The complete reference for AcelleMail's REST API — all resources, all routes, the per-endpoint request/response shapes, and the scoping rules. Use this alongside the getting-started guide; this article is the lookup table.
- [AcelleMail Webhook Signature Verification and Replay Protection](https://acellesend.com/kb/articles/acellemail-webhook-signature-verification-and-replay-protection): When you receive a webhook from AcelleMail (or AcelleMail receives one from a third-party event source like Meta), the receiver has no built-in trust that the request came from where it claims. This guide walks the HMAC-SHA256 signature verification pattern AcelleMail uses internally for Ads webhook reception, the constant-time-compare hazard most operators get wrong on first attempt, the replay-protection cache pattern, and how to apply the same techniques to your own outbound integrations.
- [BIMI with VMC — Cost, Process, and Whether It's Worth It](https://acellesend.com/kb/articles/bimi-with-vmc-cost-walkthrough): VMC (Verified Mark Certificate) costs $1,500/yr per domain and takes 4-8 weeks to obtain. This guide walks the cost math, the trademark requirements, the alternative paths, and a decision framework for whether VMC is right for your sending program.
