mettle/sendportal
SendPortal is a self-hosted email marketing platform built on Laravel. Manage subscriber lists, compose and send campaigns through your own SMTP or API providers, and track opens and clicks in a clean dashboard. Open source, with no per-subscriber fees.
SendPortal is a self-hosted email marketing platform you install into a Laravel app rather than a drop-in CRUD package — it brings its own models, routes, views, jobs, and migrations under the Sendportal\Base namespace.
Minimal path:
composer require mettle/sendportal in a fresh-ish Laravel app (check the package composer.json for the supported Laravel/PHP matrix — the 4.x line expects modern PHP 8.x, which matters if you're bolting this onto a legacy app).php artisan sp:install — publishes config/views/assets, runs migrations, and creates the initial admin user. On 4.0.0 this command silently skipped migrations; the fix is in 4.0.0.1, so pin ^4.0.0.1 or newer and verify tables exist afterwards.Where to look first:
config/sendportal.php — route prefix, middleware stack, model bindings, provider config. This is the file you'll touch most.vendor/mettle/sendportal/src/Base/ — the Services/ directory is where the real logic lives (message dispatch, content parsing, segment resolution).vendor:publish with the sendportal tags) before you start restyling; don't edit anything in vendor/.First real use case: a one-off newsletter to an existing user table. Import subscribers into a list via the UI/CSV or a small artisan command, build a Blade-style template, create a campaign against that list, and send. That flow exercises every subsystem you'll later need to extend.
Provider abstraction. Each sending provider is an EmailService record with a type and a JSON settings blob. Under the hood these resolve to message-service classes that implement a common contract, which is the natural extension point if you need a provider that isn't baked in — implement the contract, register the mapping, and it slots into the campaign sending flow.
Campaign lifecycle is queue-driven. Draft → queued → sending → sent. Campaigns fan out into per-recipient jobs, so a real queue connection and a running worker are non-negotiable for anything beyond a smoke test. Pair this with rate limiting (job middleware / Redis::throttle) if your provider caps throughput.
Scheduling. Scheduled campaigns rely on Laravel's scheduler, not a daemon inside the package. Ensure schedule:run is on cron (or schedule:work locally) or scheduled campaigns will sit in draft forever with no error.
Segments vs. lists vs. tags. Lists are static membership; tags are labels; segments are saved rule sets evaluated at send time. If you need dynamic audiences from your own domain tables, the segment rule engine is the thing to study and extend rather than duplicating subscriber rows.
Templates and merge tags. Templates are Blade-flavoured with per-recipient merge tags resolved at send time. Before authoring a large template library, read the content-parsing service in src/Base/Services/Content/ and confirm the exact tag syntax and escaping behaviour — the escaping rules differ from raw Blade and will bite you on names containing quotes or HTML.
Webhooks. Open/click/bounce tracking depends on provider webhooks pointing at the package routes. In practice: configure them per environment, keep the endpoint public, and treat signature verification failures as a first-class debugging concern — dropped webhooks mean silently wrong stats, not errors.
Extending without forking. Publish views, swap model bindings via config, hook package events (MessageSent, subscriber events) into your own listeners, and prefer decorating the service classes over editing vendor code. Multi-tenancy/workspaces is not built in — if you need per-account isolation, expect to fork or wrap it, and budget accordingly.
php artisan route:cache, which will fail your deploy pipeline even though the app runs fine locally. The install fix is equally relevant to anyone provisioning fresh environments.route:clear and then route:cache as an explicit check. Route-cache regressions are the classic "works in dev, dies in deploy" failure for this package.sp:install — don't trust the command's silence, especially if you're on a pinned older patch. php artisan migrate:status is your friend.config:cache after editing config/sendportal.php, then config:clear when debugging why a route prefix or middleware change "didn't take".vendor/; publish/run them, then make your own additive migrations for custom columns rather than patching the originals.failed_jobs, not the UI. When a campaign reports "sending" indefinitely, check the queue worker first, the job payload second, and the provider credentials last.localhost; don't chase a phantom bug.How can I help you explore Laravel packages today?