libre-informatique/email-crm-bundle
Email CRM bundle for PHP applications, offering reusable components to connect email interactions with customer records and support lightweight CRM workflows. Designed for straightforward integration into existing projects and adaptable to common contact-management needs.
Acknowledge the framework gap on day one. libre-informatique/email-crm-bundle is a Symfony bundle. There is no ServiceProvider, no config/email-crm.php, no facade, no php artisan command exposed to Laravel. It registers through config/bundles.php, is configured via YAML under config/packages/, and is wired by Symfony's DependencyInjection compiler pass. So "day-to-day leverage" in a Laravel codebase looks like one of two things:
NOASSERTION license), orDo not composer require it into a Laravel app and expect anything to boot. Composer will happily install it, and nothing will happen.
Minimal steps to inspect the package:
# Do NOT add to your app's composer.json. Clone in isolation.
git clone https://github.com/sil-project/EmailCRMBundle.git ../../reference/email-crm-bundle
cd ../../reference/email-crm-bundle
# Read the manifest first — this tells you the supported PHP/Symfony range
cat composer.json
# Check whether a LICENSE file actually exists (NOASSERTION is a warning)
ls -la | grep -i licen
Where to look first, in order:
composer.json — supported PHP/Symfony constraints, whether it pulls swiftmailer/swiftmailer (EOL since late 2021) or symfony/mailer. This single file decides whether the code you're reading is even portable to a modern Laravel 10/11 app.DependencyInjection/Configuration.php — the canonical list of configurable behaviors. This is your feature inventory: what entities exist, what's toggleable, what a "campaign" means to the authors.Resources/config/*.yml — service wiring and Doctrine mappings. Reveals the domain boundaries (contact / thread / message / list / campaign) and any hidden dependencies on sibling Libre Informatique bundles.Entity/*.php (or Model/*.php) — the actual data model. This is the only asset you're really harvesting.Manager/*.php / Repository/*.php — the write paths and query shapes. Read these to understand invariants (e.g., "a message belongs to exactly one thread", "a contact can appear on many lists").First use case — a 90-minute "read and sketch":
# Dump every Doctrine column to a single file you can read in one pass
grep -rnE '@(ORM\\Column|ORM\\ManyToOne|ORM\\OneToMany|ORM\\ManyToMany|ORM\\JoinColumn)' \
Entity/ | tee /tmp/schema-sketch.txt
Then hand-translate that into a Laravel migration sketch. That sketch is your spike deliverable: a contacts, email_lists, email_list_contacts, email_threads, email_messages, and email_campaigns set of tables. If the schema that falls out of that exercise maps cleanly onto your product's needs, you've extracted the value this bundle has to offer. If it doesn't, you've closed out the evaluation for the cost of one afternoon.
The patterns below are how you'd use the ideas in Laravel day-to-day. There is no supported pattern that lets you use the bundle itself as a runtime dependency in a Laravel app.
Pattern 1 — Extract the domain model into Eloquent, not the bundle.
Treat the Doctrine entities as a spec sheet. Port relations one-for-one, not the annotations:
// app/Models/EmailMessage.php — ported shape, not a dependency
class EmailMessage extends Model
{
protected $fillable = ['thread_id', 'contact_id', 'direction', 'subject', 'body', 'sent_at'];
public function thread(): BelongsTo { return $this->belongsTo(EmailThread::class); }
public function contact(): BelongsTo { return $this->belongsTo(Contact::class); }
}
The Doctrine mappings for ManyToOne/JoinColumn translate directly to belongsTo/hasMany + foreignId()->constrained(). Many-to-many with a join entity becomes belongsToMany with ->using(Pivot::class) if you need extra columns.
Pattern 2 — Adapter boundary around the mailer.
The bundle's send path is almost certainly SwiftMailer-era. In Laravel, do not let the bundle's sending concepts leak into your code. Route everything through an interface you own:
interface CrmMailer
{
public function send(Campaign $campaign, Contact $contact, EmailTemplate $template): SendRecord;
}
Back it with Laravel's Mail/Notification facade and a queued Mailable. This is the seam that lets you swap in Mailgun/Postmark/SES later without touching CRM code. If you ever do port the bundle's send orchestration, wrap it in exactly this interface so it's replaceable from day one.
Pattern 3 — Sync the email flow through Laravel's pipeline, not the bundle's.
Symfony bundles like this wire up event subscribers and manager services. In Laravel, mirror the intent with Jobs and Listeners:
// The bundle concept → Laravel equivalent
// EventDispatcher + Subscriber → Event/Listener pairs
// Manager::send() → Queued Job
// Doctrine UnitOfWork flush → DB::transaction()
Concretely: an inbound-email webhook listener that creates a Contact if missing, resolves or creates a Thread by message-id headers, appends an EmailMessage, and dispatches CrmActivityRecorded. That's the entire "email management" value proposition, and it's ~150 lines of Laravel code.
Pattern 4 — Use the bundle's config as a requirements checklist.
Reading DependencyInjection/Configuration.php gives you an authoritative list of options the authors thought mattered. Turn each into a config entry in config/email-crm.php and a test case. This is the highest-leverage thing you can do with an undocumented, unmaintained package: it's a free feature inventory against which to scope your own build.
Pattern 5 — If you must run Symfony alongside Laravel (rare but real).
The only viable integration is data-level, not runtime-level: run the Symfony service on its own PHP runtime, share the database, and expose it via HTTP. Laravel talks to it via a Http:: client or a job queue (e.g., Redis/SQS). This is the only pattern where the bundle can actually stay as-is. Anything else means porting entities and losing the "reusable bundle" premise. Do not attempt to embed the Symfony kernel in a Laravel process.
Workflow that works for a Laravel team:
composer.json, Configuration.php, Entity/*.php (one hour).NOASSERTION means no identified license; shipping it into a commercial app is a risk that no engineering benefit here outweighs.It's not a Laravel package — and it will never be one. The framework-fit gap is the biggest gotcha and the one most likely to be missed. Everything else in this section is a consequence of it. The bundle assumes a Symfony kernel, the DI container, and Doctrine's EntityManager. None of those exist in a Laravel request lifecycle. Anything you "get working" by manually instantiating bundle services in a Laravel service provider is unsupported and will be a maintenance burden you can't outsource.
License is NOASSERTION — stop before you write product code.
That means GitHub could not identify an OSI license. There may be no LICENSE file, or a broken one, or a custom notice. Do not ship it into commercial or client-facing code until counsel has read it. This is not a caveat that can be waived with a comment in composer.json.
Version constraints will bite if you try a Composer install.
Expect php: ^7.x and symfony/symfony: ^3.4 || ^4.x in the manifest. Modern Laravel requires PHP 8.1+, and Composer's solver will fail on transitive conflicts before you even reach the "it doesn't boot" stage. If you're reading the manifest and seeing swiftmailer/swiftmailer, that's the EOL mailer — any transport code you port must be rewritten against symfony/mailer or, in Laravel, Illuminate\Mail.
Doctrine annotations ≠ Eloquent attributes.
If you're copying entity code, the Doctrine metadata lives in PHP 8 attributes or docblock annotations (@ORM\Column(type="string", nullable=true)), which Eloquent will silently ignore if pasted in. Strip all ORM\* metadata and hand-write the equivalent migration. Watch for:
nullable=true — Eloquent columns default to NOT NULL unless you say otherwise.cascade={"remove"}) — Eloquent needs ->onDelete('cascade') on the FK.type="integer" with PHP-level enum handling doesn't map to Laravel enums; you'll choose one or the other.DateTime / timezone handling — Doctrine's \DateTime defaults may differ from what ->usesTimestamps() does.Bundle config is not Laravel config.
YAML Resources/config/*.yml files define service arguments. When porting to config/email-crm.php, map each argument carefully — Symfony's %env()% parameter resolution has no direct Laravel analog (it's env() / config(), and Laravel's config caching will freeze values at boot). Watch for %kernel.project_dir%-style path references that don't exist in Laravel.
Don't count on tests, docs, or CI.
Star count is 1, dependents are 0, there's no visible release cadence. Treat the source as the only source of truth. Run composer audit on any fork you create, generate an SBOM, and expect to add your own PHPStan config — upstream almost certainly had none that runs today.
Deliverability and compliance are not covered.
This is "email management" in the domain-modeling sense (threads, campaigns, send records), not in the "bounce handling, complaint processing, unsubscribe lists, DKIM/DMARC, GDPR retention" sense. Laravel side, that means: use Mailgun/Postmark/SES webhooks for bounces and complaints, implement your own suppression list, and add a retention_policy_days config before you ship. None of this comes free with the bundle, and none of it comes free when you port it either.
Async send is not baked in — you must add it. The bundle's send path is synchronous by design (SwiftMailer call, then persist the record). Laravel's queued Mailables are the correct replacement. Two specifics that will bite:
send_record row with a unique constraint on (campaign_id, contact_id) before queuing the send. Otherwise a queue retry double-sends.Bus::batch([...]) or a chunked job, and rate-limit per provider. Sending 10k recipients one Mail::to() at a time from a single worker is a queue-starvation risk that has nothing to do with the bundle and everything to do with your dispatch design.Extension points are the only thing worth keeping. If the bundle has an extension surface (custom fields, event subscribers, workflow hooks), that's a domain design signal — port the concept into Laravel's own extension mechanism (events, listeners, config-driven field maps in a JSON column). Don't port the mechanism itself; Symfony's event subscriber pattern is similar to Laravel's listeners in intent, but they're not interchangeable in code, and Laravel's contract for dispatch-order guarantees is different.
When you see LibreInformatique\...\Blast or Sylius\... in the imports, stop.
The bundle belongs to the Libre Informatique Blast ERP/CRM family and likely pulls in Sylius-derived bundles (ResourceBundle, GridBundle, Registry). That's a large transitive tree you'd own permanently. If the imports show those packages, the realistic integration cost is "fork and maintain an ERP fragment," not "lift a domain model" — which is another reason to close out the evaluation at the inspection step.
How can I help you explore Laravel packages today?