libre-informatique/crm-bundle
Symfony CRM bundle from Libre Informatique that adds ready-made customer relationship entities—contacts, organisations and linked address books—on top of the Blast core bundle. Doctrine-backed, extensible and easy to integrate into existing Symfony apps.
First, calibrate expectations: this is a Symfony bundle, not a Laravel package. There is no ServiceProvider, no extra.laravel block, no facade, no publishable config. composer require libre-informatique/crm-bundle into a Laravel app will install a package nothing boots. Your day-to-day leverage is therefore one of two things:
In the repo, in this order:
| Path | Why it matters |
|---|---|
LICENSE |
Reads as NOASSERTION. This is the gate. Read it before writing a line of code. |
composer.json |
Pins for symfony/*, doctrine/orm, PHP version, and the sibling libre-informatique/* deps. |
src/Entity/ (or Model/) |
The actual CRM schema. This is the asset you are harvesting. |
src/Repository/ |
Query semantics: what "active contact", "open opportunity", "recent activity" mean. |
DependencyInjection/Configuration.php |
What is configurable (relations, toggles) — tells you which schema choices were meant to be swapped. |
Resources/config/doctrine/ or entity annotations |
Mapping details: join tables, naming strategy, cascade rules. |
Pick a thin vertical: contacts belonging to companies, with an activity feed of notes and calls. That single slice exercises the party/relationship modeling, the polymorphic activity association, and the address/communication-channel fields — the three things that are annoying to get right from scratch.
Minimal steps:
If legal is unresolved, stop at step 1 and use the bundle purely as a reading exercise.
Map the Symfony/Doctrine entities to Laravel models. Typical shape:
| Symfony entity | Laravel model | Notes |
|---|---|---|
Contact |
Contact |
Has address/phone/email fields or embedded value objects — flatten to columns or a JSON column. |
Company / Organisation |
Company |
Contact belongsTo Company; decide nullable vs. required on day one. |
Activity / Interaction (call, email, note) |
Activity |
Polymorphic subject() pointing at Contact and Company; type enum cast. |
Embedded Address |
columns or json cast |
Doctrine embeddables do not translate; pick one representation and stay consistent. |
Migrations — translate the semantics, not the DDL:
Schema::create('companies', function (Blueprint $t) {
$t->id();
$t->string('name')->index();
$t->string('website')->nullable();
$t->string('phone')->nullable();
$t->json('address')->nullable(); // flattened embeddable
$t->timestamps();
$t->softDeletes(); // explicit choice, not inherited
});
Schema::create('contacts', function (Blueprint $t) {
$t->id();
$t->foreignId('company_id')->nullable()->constrained()->nullOnDelete();
$t->string('first_name');
$t->string('last_name');
$t->string('email')->nullable()->index();
$t->string('phone')->nullable();
$t->json('address')->nullable();
$t->timestamps();
$t->softDeletes();
});
Schema::create('activities', function (Blueprint $t) {
$t->id();
$t->morphs('subject'); // contact or company
$t->string('type')->index(); // call | email | note
$t->text('body')->nullable();
$t->timestamp('occurred_at')->nullable();
$t->timestamps();
});
Models with enum casts and a strict morph map:
class Activity extends Model
{
protected $guarded = [];
protected $casts = [
'type' => ActivityType::class,
'occurred_at' => 'datetime',
];
public function subject(): MorphTo
{
return $this->morphTo();
}
}
// AppServiceProvider::boot()
Relation::enforceMorphMap([
'contact' => Contact::class,
'company' => Company::class,
]);
Isolate it in a domain namespace (app/Domain/Crm/...) with a small service layer — CreateActivity, TransferContactToCompany, etc. When (not if) you later swap to a SaaS CRM or a vetted package, the change is a namespace-level swap rather than a grep across controllers.
Only when a hard requirement forces reuse of existing CRM records:
CrmClient — with timeouts, a circuit breaker, and idempotency keys on writes.interface CrmClient
{
public function upsertContact(ContactData $data): ContactId;
public function listActivities(ContactId $id): Collection;
}
Keep the adapter thin; put the retry/circuit-breaker in middleware so no caller has to know.
Filament resources map cleanly onto a harvested model. Use Select::make('company_id')->relationship(...), Repeater for the activity log, and a MorphToSelect for the activity subject. This is the fastest way to get a usable CRM UI without touching Symfony Forms or Twig.
The license is the real blocker. NOASSERTION means GitHub could not classify LICENSE. Get a human to read it and the per-file headers before any shipped code. Internal-only prototype is a much lower bar than redistribution. If it does not clear, the harvest pattern is still safe — schema ideas are not copyrightable, code is.
Do not import the Doctrine DDL. Symfony bundles of this vintage use annotation mapping with implicit conventions — naming strategies, join-table names, cascade rules — that produce surprising SQL. Translate to your own migrations and make soft-delete, audit-column, and nullability decisions explicitly.
The sibling bundle graph. Libre Informatique packages (blast-*, semantic-*, Sonata admin) tend to reference each other by class name. Pulling one bundle alone can leave unresolvable deps. Before any Symfony-side attempt, resolve the full graph in a scratch project.
You will not find a service provider. Don't grep for one, don't try composer require and wait for auto-discovery. If a teammate tried this, the empty boot is not a bug in the package.
Zero upstream support. Three stars and zero dependents means no CVE feed, no issues triage, no Stack Overflow answers. Assume you are the maintainer of any fork, and write an ADR so the "should we use this?" question doesn't resurface next quarter.
Framework version ceiling. Check the symfony/* and PHP pins in composer.json. An unmaintained bundle will fall behind Symfony's yearly majors within a release or two, which blocks your own upgrades if you bridge it.
Data ownership, decided on day one. If you run the standalone service, exactly one system writes CRM records. "Current owner" and dedup semantics are where eventual consistency quietly breaks — reads can be cached, writes should be queued, and the product must accept it explicitly.
Don't dual-boot a Symfony kernel in a Laravel process. Two containers, two ORMs, two config systems, shared request lifecycle — this is a known anti-pattern with unpredictable failure modes in queues and under load. If you need the code, run it out-of-process or reimplement.
Where the real extension points land in Laravel: custom casts for addresses and enums, observers for activity fan-out, policies for record visibility, and a Crm domain namespace as the seam for a future swap. Those are the transferable equivalents of the bundle's Symfony DI extension and configuration tree.
Debugging the harvest: when the reimplementation behaves differently from the Symfony original, bisect on invariants first (uniqueness of email per company, cascade on company deletion, whether an activity can have no subject). Those are where Doctrine magic tends to hide behavior you did not notice on read.
How can I help you explore Laravel packages today?