leads/core
A Laravel/PHP core package for lead management, offering reusable models, contracts, and services to capture, organize, and track leads. Provides a lightweight, extensible foundation for building custom lead workflows.
Before writing a line of application code, establish what you actually have. leads/core has zero stars and zero dependents, but release 1.2.1 confirms it is a real, sequentially versioned package with a PR workflow (#20) and a tagged predecessor (1.2.0). That is a meaningful upgrade in confidence over a single orphan tag — but it is still an unvetted package with no public consumers. Treat it as pre-1.0 in spirit regardless of the version number.
1. Verify the artifact before installing
# Confirm which tags actually resolve
composer show maxim-intelico/leads-core --all
# Inspect the tag you intend to pin, and the fix you are relying on
git ls-remote --tags https://github.com/Maxim-intelico/leads-core
git log --oneline 1.2.0..1.2.1
Pin a floor, not just a ceiling, because 1.2.1 fixes a datetime casting bug you do not want to inherit:
{
"require": {
"maxim-intelico/leads-core": "^1.2.1"
}
}
If it is not on Packagist, wire it in as a VCS repository and pin a commit — never a branch:
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/Maxim-intelico/leads-core" }
],
"require": {
"maxim-intelico/leads-core": "dev-main#<full-commit-sha>"
}
}
2. Read composer.json first, not the README
The extra.laravel.providers and extra.laravel.aliases keys tell you whether auto-discovery is in play, and require tells you the Laravel/PHP floor. If the constraint is loose (^8|^9|^10), assume only the lowest version is actually tested.
3. Publish and inspect before migrating
php artisan vendor:publish --provider="Leads\Core\LeadsCoreServiceProvider"
php artisan migrate --pretend # read the SQL before touching a real database
Read database/migrations directly — the schema is the real documentation. Table names, nullability and indexes will tell you more about intended usage than any prose. Pay particular attention to datetime vs timestamp column types, given what 1.2.1 changed.
4. Where to look, in order
| Location | Why |
|---|---|
src/Contracts or src/Contracts/* |
The intended extension surface — bind these, not concretes |
src/Casts/DateCaster.php |
Custom date handling; changed in 1.2.1, so read the current implementation |
config/leads.php |
Status lists, table prefixes, driver choices |
database/migrations |
Source of truth for the data model |
src/Models |
Relationships, casts, scopes, soft deletes |
tests/ |
If absent, you are the test suite |
5. First use case: capture → persist → assign → list
Start with the narrowest vertical slice. Post a lead through a form request, persist it via the package's model or service, set an initial status, and render it in a listing. Write this as a feature test on day one so you have a regression harness before you build anything else. Include a datetime with non-zero seconds, since that is precisely what 1.2.1 touched:
it('captures a lead with a default status', function () {
$response = $this->postJson('/api/leads', [
'name' => 'Ada Lovelace',
'email' => '[email protected]',
'contacted_at' => '2026-03-14T09:26:53Z',
]);
$response->assertCreated();
$this->assertDatabaseHas('leads', ['email' => '[email protected]']);
expect(Lead::first()->contacted_at->second)->toBe(53);
});
If that test is hard to write, the package's boundaries are wrong for your app and you should wrap it immediately (see below).
Treat the package as an infrastructure dependency, not your domain model
With no dependents and no community, the public API is effectively unstable — a patch release fixing a caster is proof that behaviour-changing fixes arrive without a major bump. Isolate it behind your own layer so a fork or replacement is a one-file change:
interface LeadIntake
{
public function capture(LeadData $data): LeadId;
}
final class PackageLeadIntake implements LeadIntake
{
public function __construct(private readonly LeadService $leads) {}
public function capture(LeadData $data): LeadId
{
$lead = $this->leads->create($data->toPackagePayload());
return new LeadId($lead->getKey());
}
}
Your controllers, jobs and API resources depend on LeadIntake, never on Leads\Core\Models\Lead. This is the single highest-leverage decision you can make with a package at this maturity level.
Bind contracts in your own provider, after the package provider
Extension points almost always exist as interfaces registered in the package's service provider. Override them in AppServiceProvider::register() (or a dedicated LeadServiceProvider registered later in config/app.php) so your binding wins:
public function register(): void
{
$this->app->bind(
\Leads\Core\Contracts\LeadRepository::class,
\App\Leads\DatabaseLeadRepository::class
);
}
Verify the resolution order with app()->make(...) in Tinker rather than assuming.
Drive status transitions through one place
Lead packages almost always ship a status/pipeline concept, and almost always as a hard-coded enum or a config array. Funnel every transition through a single service method so you can later add audit logging, guards and afterCommit events without hunting call sites:
final class TransitionLeadStatus
{
public function __construct(private readonly LeadIntake $leads) {}
public function handle(LeadId $id, LeadStatus $to): void
{
DB::transaction(function () use ($id, $to) {
$this->leads->transition($id, $to);
LeadStatusChanged::dispatch($id, $to); // queued, afterCommit
});
}
}
Normalize intake at the edge, not in the package
Email lowercasing, phone E.164, source attribution, UTM capture — do all of it in a FormRequest or DTO before the package sees the data. You cannot assume the package normalizes anything, and its unique indexes will happily accept [email protected] and [email protected] as distinct leads.
Be explicit about timezones and precision at the boundary
DateCaster now accepts minute-precision datetimes, which tells you the caster was previously doing its own parsing rather than delegating to Carbon cleanly. Do not rely on the caster as your timezone boundary. Convert to UTC in your DTO, hand the package a Carbon instance or a fully qualified ISO-8601 string, and let the caster do as little as possible:
final readonly class LeadData
{
public function __construct(
public string $name,
public string $email,
public ?CarbonImmutable $contactedAt = null,
) {}
public static function fromRequest(StoreLeadRequest $request): self
{
return new self(
name: $request->string('name')->trim()->value(),
email: strtolower($request->string('email')->trim()->value()),
contactedAt: $request->date('contacted_at')?->utc(),
);
}
}
Assignment and ownership
If the package has assigned_to/owner_id columns, integrate with your User model via a morph or explicit FK in your own migration, and add a global scope for tenant isolation in your wrapper repository rather than relying on the package. Multi-tenancy is the first thing to verify, because retrofitting it into a third-party schema is painful.
Queue the expensive parts
Scoring, enrichment, deduplication and outbound webhooks belong in jobs. Listen to the package's events (check src/Events or grep -r "dispatch(" src) and fan out from listeners registered in your EventServiceProvider. This keeps HTTP lead capture fast and makes retries free.
Testing strategy
RefreshDatabase plus the package migrations; do not mock the database.LeadIntake in feature tests; use the real implementation in a small set of integration tests. That split is what protects you when the package changes.1.2.1 is a silent-behaviour-change patch — read the diff
"Accept minute-precision datetimes in DateCaster" means 1.2.0 and earlier did not correctly accept them. Whatever the old behaviour was — throwing, truncating, silently coercing to midnight, or storing Y-m-d H:i:00 — your tests and fixtures were shaped around it. On upgrade:
The release timestamps still look wrong
The prior tag carried a 2026-08-25 stamp. Sequential versioning and a real PR link make fabrication less likely and a skewed clock or mis-set date in a release script far more likely — but verify rather than assume:
git log -1 --format=%cI 1.2.1
git tag -v 1.2.1 # signed?
Do not deploy without reading the diff of the tag you pin.
Custom casts mean $casts is not yours to guess
The existence of a DateCaster implies the models declare explicit $casts entries rather than relying on Laravel's built-in datetime. If you subclass a package model or add your own attribute, do not assume protected $casts = ['contacted_at' => 'datetime'] behaves identically to the package's caster. Grep src/Models for casts and match whatever they use, or you will get inconsistent serialization in API resources.
Zero dependents means zero field testing
No one else is running this against a real Laravel app. The migrations may not run on MySQL 8, the service provider may not be auto-discoverable on Laravel 11+, the composer.json version constraints may be wrong. Budget a half-day for "make it boot" before estimating feature work. Fork it into your own vendor namespace on day one; you will almost certainly need to patch something.
Migrations collide and are hard to reverse
Publish migrations, rename tables with a prefix in config/leads.php if supported, and edit the published files rather than the vendor copies. Always run migrate --pretend first, and check whether the package migrations are wrapped in a transaction that will fail on DDL in MySQL. Add a down() review — many small packages ship broken rollbacks.
Config caching bites on first deploy
If config/leads.php reads env() at runtime in a path outside the config file, php artisan config:cache will silently return null in production. Grep the src/ tree for env( — anything outside config/ is a bug you should patch in your fork. Always re-run config:clear after publishing.
Provider registration order
Auto-discovery registers the package provider early. If your override binds in AppServiceProvider, confirm it wins. The reliable pattern is a dedicated provider listed after the package in bootstrap/providers.php (Laravel 11+) or config/app.php. Test it with php artisan about and a Tinker resolution check, not by reading code.
Events inside transactions
If the package dispatches domain events inside its own DB::transaction, your listeners can fire on rolled-back data. Push anything with side effects (mail, webhooks, queue jobs) onto ShouldQueue listeners or wrap with DB::afterCommit(). Audit every dispatch( call in src/.
Normalization and uniqueness
Assume nothing. Add a migration for a functional/case-insensitive unique index on email if the package's own index is naive, and normalize phone numbers before insert. Duplicate leads are the most common complaint against homegrown lead packages, and this one has no issue tracker history to learn from.
N+1 on listing pages
Listing leads with status, owner and source is the classic N+1 shape. Before you build the index screen, Model::preventLazyLoading() in a local environment and eager-load explicitly in your repository. Small packages rarely ship scopes that help here.
Extension points to look for, in priority order
config/leads.php (e.g. models.lead) — lets you subclass Lead and add relations without forking.Resolver for status lists.If none of 1–3 exist, your only extension point is the fork. Say so explicitly in your estimate rather than absorbing the risk silently.
Debugging toolkit
php artisan about # confirm provider + config loaded
php artisan tinker # resolve contracts, inspect bindings
DB::listen(fn ($q) => logger()->debug($q->sql, $q->bindings));
composer audit # check transitive deps for advisories
composer outdated maxim-intelico/leads-core # know when 1.2.2 lands
Exit strategy
Write down, before you start, what "we replace this" looks like. Because your controllers depend on LeadIntake and not on Leads\Core\*, the replacement is a new implementation plus a data migration. A package with 0 stars and 0 dependents has a meaningful chance of being abandoned — and the fact that its only release in view is a one-line caster fix suggests a very small maintainer surface. The wrapper is what makes abandonment a Tuesday afternoon instead of a quarter.
How can I help you explore Laravel packages today?