phplist/phplist4-core
Core library for phpList 4, the open-source newsletter and email marketing platform. Provides foundational PHP components for managing subscribers, mailing lists, campaigns, and queue processing, enabling developers to build and extend self-hosted email marketing systems.
phpList 4 is split into modules (base, core, rest-api, web); phplist/phplist4-core is the domain layer only — entities, repositories, and services for subscribers, lists, campaigns, templates, admins, and bounces. It is not a Laravel package: there is no service provider, no config file, and no facade. You wire it yourself.
composer require phplist/phplist4-core
Minimum viable setup:
composer.json first. It tells you the supported PHP range and the pinned Doctrine/Symfony versions. This package last shipped in March 2021, so expect doctrine/orm ^2.x and annotation-based mappings.src/Domain/Model/ to learn the entity graph. The important ones for day-to-day work:
Subscriber\Subscriber — email, confirmed, blacklisted, bounceCountSubscription\SubscriberList — the list itselfSubscription\Subscription — the join row between subscriber and list (this is where "subscribed" actually lives)Message\Campaign, Message\Template — campaigns inherit from a shared message baseAdministrator\Administrator — admin accounts and password recordssrc/Domain/Repository/ next. Everything extends AbstractRepository with store(), remove(), find(), findAll(), and findOneBy… helpers. These are your practical entry points.src/Domain/Service/. Services encode the invariants (uniqueness, hashing, subscription rules). If a service exists for your operation, use it instead of the repository — it saves you re-deriving the rules.First use case: create a subscriber and subscribe them to a list.
$subscriber = new Subscriber();
$subscriber->setEmail('[email protected]');
$subscriber->setConfirmed(true); // otherwise mailings silently skip them
$subscriberRepository->store($subscriber);
$list = $listRepository->find(1);
$subscription = new Subscription();
$subscription->setSubscriber($subscriber);
$subscription->setSubscriberList($list);
$subscriptionRepository->store($subscription);
You will need a Doctrine EntityManager before any of this works — see the next section.
The core has no bootstrap; it expects a configured EntityManager. The cleanest pattern is a dedicated provider that reuses Laravel's DB connection:
// app/Providers/PhpListServiceProvider.php
public function register(): void
{
$this->app->singleton(EntityManager::class, function ($app) {
$config = Setup::createAnnotationMetadataConfiguration(
[base_path('vendor/phplist/phplist4-core/src/Domain/Model')],
$app->environment('local'),
storage_path('doctrine-proxies'),
);
$conn = DriverManager::getConnection(
config('database.connections.phplist'),
$config,
);
return EntityManager::create($conn, $config);
});
$this->app->bind(SubscriberRepository::class, fn ($app) =>
new SubscriberRepository($app->make(EntityManager::class))
);
}
Keep the phpList tables on their own connection name in config/database.php. It makes the boundary explicit and lets you point at a legacy phpList database without touching your app schema.
A workable rule that holds up over time:
findOneByEmail, findBy(['blacklisted' => false]), and DQL projections are fine and fast.The most common real-world integration is "sync my app's users into phpList." Run it as a queued job, one transaction per batch, and be idempotent:
foreach ($batch as $row) {
$subscriber = $subscriberRepository->findOneByEmail($row['email'])
?? new Subscriber();
$subscriber->setEmail($row['email']);
$subscriber->setConfirmed(true);
$subscriberRepository->store($subscriber);
}
Because ids are auto-increment and email uniqueness is enforced at the DB level, wrap the loop in a transaction and catch Doctrine\DBAL\Exception\UniqueConstraintViolationException to skip duplicates rather than aborting the whole batch.
Campaigns are assembled from three pieces: a Campaign (subject, sender, HTML/text body), a Template (optional reusable wrapper), and the target lists. The list association goes through the campaign's list relation — set it before the campaign is queued/sent, and re-fetch the campaign afterwards if you plan to read it back, since the association is not always eagerly populated.
Do not try to replace Laravel's auth with the core's Administrator. Instead:
Administrator records only if the phpList web UI needs them.Hash::check will not work.The core was designed to be driven from Symfony Console in the web module. That makes it a natural fit for Laravel's scheduler and queues: every console command upstream becomes a job or a scheduled task in your app, calling the same services. Anything you can do in a controller you can do in a job — that's the intended usage.
AGPL-3.0 is the biggest gotcha. This is a network-copyleft license. If you modify the core and expose it as a service, you are expected to offer the modified source to users. Bundling it into a closed-source SaaS without legal review is a real risk — get sign-off before you build on it commercially.
It is dormant. Last release 2021-03-02, 89 stars. Treat it as a stable-but-frozen dependency: pin exact versions, vendor the lockfile, and budget time for the PHP 8 upgrade yourself. doctrine/annotations is deprecated and will need an AttributeDriver migration on your side eventually.
Annotation mappings, not attributes. Entity metadata lives in docblock annotations. Proxy generation needs a writable directory — storage_path('doctrine-proxies') above — and will fail silently-ish in production if it isn't writable on first request.
Setters are sparse. Do not assume a fluent entity. Many properties are set only at construction or via a specific method (confirm(), setPasswordHash()), and some have no public setter at all. When you hit a wall, look for the matching service first; if there isn't one, subclass the entity in your own namespace rather than reaching for reflection.
confirmed and blacklisted are load-bearing. A subscriber with confirmed = false will be quietly excluded from mailings. This is the single most common "why didn't my import send anything?" bug. Set it explicitly on every write path.
Password hashes are phpass-style ($P$), not bcrypt. Hash::check and password_verify will both return false. If you need to verify or migrate, include a compatible hasher and upgrade hashes on next successful login.
Campaign bodies are large. Campaign and Template carry full HTML/text content. Listing campaigns eagerly loads megabytes. Use DQL projections (SELECT c.id, c.subject, c.creationDate) for index screens and fetch the full entity only on the detail view.
Email uniqueness may not be enforced by the core's validation path. The schema has the constraint; the PHP layer does not always check first. Catch UniqueConstraintViolationException at the boundary and convert it to a validation error rather than a 500.
Extension points are inheritance, not interfaces. Repositories are easy to extend (subclass AbstractRepository); services are concrete classes without contracts. Decorate by wrapping or subclassing, and register your subclass in the container instead of the original.
Debugging tip: when a store() call does nothing visible, check whether the entity is actually managed. A new Entity() that was never passed through persist()/store() is detached, and changes to it are silently lost when the EntityManager is flushed. Add a $em->contains($entity) assertion in dev.
Testing: repositories need a real or in-memory EntityManager — SQLite :memory: with schema tool export works well. The sibling phplist/base module ships test helpers if you want the upstream patterns; otherwise a per-test EntityManager fixture is about twenty lines and worth writing once.
How can I help you explore Laravel packages today?