Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Phplist4 Core Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Read 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.
  2. Skim src/Domain/Model/ to learn the entity graph. The important ones for day-to-day work:
    • Subscriber\Subscriber — email, confirmed, blacklisted, bounceCount
    • Subscription\SubscriberList — the list itself
    • Subscription\Subscription — the join row between subscriber and list (this is where "subscribed" actually lives)
    • Message\Campaign, Message\Template — campaigns inherit from a shared message base
    • Administrator\Administrator — admin accounts and password records
  3. Look at src/Domain/Repository/ next. Everything extends AbstractRepository with store(), remove(), find(), findAll(), and findOneBy… helpers. These are your practical entry points.
  4. Only then look at 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.

Implementation Patterns

Wiring Doctrine into Laravel

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.

Repositories for reads, services for writes

A workable rule that holds up over time:

  • Reads / list screens / exports — go straight to repositories. findOneByEmail, findBy(['blacklisted' => false]), and DQL projections are fine and fast.
  • Writes that touch invariants — go through services. Creating an administrator, changing a password, confirming a subscription, and recording a bounce all have rules baked into the service layer. Re-implementing them in a controller is how you end up with duplicate admins and unhashed passwords.

Import and sync jobs

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.

Campaign authoring

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.

Authentication bridging

Do not try to replace Laravel's auth with the core's Administrator. Instead:

  • Keep Laravel users as the source of truth for login.
  • Store phpList Administrator records only if the phpList web UI needs them.
  • If you must authenticate against phpList hashes, verify the hash format first (see Gotchas) and write a custom guard — Hash::check will not work.

Console / queue parity

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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource