Contacts and companies
The Contact and Company models, the actions that create them, corporate-domain auto-association, company enrichment, and customer health scores.
Odden\Core\Models\Contact and Odden\Core\Models\Company are the two built-in CRM records. Both use soft deletes, custom properties with change history, associations, activities, lifecycle stages, and the forTeam() scope.
Contacts
The odden_contacts table has these columns:
| Column | Type | Default |
|---|---|---|
first_name, last_name, job_title |
string, nullable | |
email |
string, required, indexed | |
phone, linkedin_url, timezone |
string, nullable | |
lifecycle_stage |
LifecycleStage enum |
lead |
became_<stage>_at |
datetime, one per stage | null |
lead_status |
LeadStatus enum |
new |
lead_score |
integer | 0 |
lead_score_updated_at, last_contacted_at |
datetime, nullable | |
properties |
JSON, cast to array | null |
owner_id |
foreign key to your user table | null |
team_id |
unsigned big integer, nullable | null |
Helpers on Contact:
full_name:first_nameandlast_namejoined, falling back toemailwhen both are empty.became_mql_atandbecame_sql_at: read-only aliases forbecame_marketing_qualified_lead_atandbecame_sales_qualified_lead_at.markContacted(?CarbonInterface $at = null): setslast_contacted_at(defaultnow()) with a normalupdate(), so model events fire and the change is written to the property history.owner():BelongsToyour user model (see the user model).companies():BelongsToManycompanies throughodden_associations, where the contact is the parent and the company the child, of any association type. The pivot includesidandtype.whereEmail(string $email)scope: lowercases and trims the value before matching.
A contact's email is lowercased and trimmed whenever it's saved, and a migration normalizes contacts saved before this was done, so an exact match on the normalized address finds every contact.
Looking up contacts by email
Odden\Core\Support\ContactLookup finds and creates contacts by email address, ignoring case and surrounding whitespace. The Service portal, chat widget, and inbound email, and Sales meeting booking all use it, so one person is one contact however they type their address.
ContactLookup::normalizeEmail(string $email): stringlowercases and trims.ContactLookup::findByEmail(string $email): ?Contactlooks for a contact stored with exactly the normalized address, which uses theemailindex. Among several matches, the oldest wins. An empty address returnsnull.ContactLookup::findOrCreate(string $email, array $attributes = []): Contactreturns the match, or creates a contact with the given attributes and the normalized email throughCreateContactAction, soContactCreatedfires and the address is checked like any other new contact. Concurrent calls for the same address are serialized with a cache lock (when the cache store supports locks), and the lookup runs again once the lock is held, so only one contact is created. Called inside a database transaction, it keeps the lock until the transaction commits, because until then other connections can't see the new contact. If the lock store is unreachable, or the lock isn't released within 10 seconds, it looks again and creates the contact without the lock rather than fail.
There is no unique index on email: contacts belong to teams, are soft-deleted, and existing data can already hold duplicates. So create contacts from an email address through findOrCreate() rather than Contact::create() or CreateContactAction when the address may already exist.
use Odden\Core\Support\ContactLookup;
$contact = ContactLookup::findOrCreate(' Dana@Example.com', ['first_name' => 'Dana']);
$contact->email; // "dana@example.com" for a new contactContact also uses Laravel's Notifiable trait. The subscription helpers that depend on Marketing's columns live in Marketing, as Odden\Marketing\Support\ContactPreferences::isSubscribedToTopic() and preferenceCenterUrl().
$contact->companies() and $company->contacts() qualify plain column names when you pluck() through them, so $contact->companies()->pluck('id') works even though the association table has its own id.
Lead status
Odden\Core\Enums\LeadStatus is a sales-qualification status, separate from the lifecycle stage. Each case has label() and color() (a Filament color name).
| Case | Value | label() |
|---|---|---|
New |
new |
New Lead |
Open |
open |
Open |
InProgress |
in_progress |
In Progress |
AttemptedContact |
attempted_contact |
Attempted Contact |
Connected |
connected |
Connected |
BadTiming |
bad_timing |
Bad Timing |
Unqualified |
unqualified |
Unqualified |
Companies
The odden_companies table has these columns:
| Column | Type | Default |
|---|---|---|
name |
string, required | |
domain, phone, industry |
string, nullable | |
lifecycle_stage |
LifecycleStage enum, nullable |
lead |
became_<stage>_at |
datetime, one per stage | null |
account_tier |
string, nullable | null |
intent_score |
unsigned integer | 0 |
intent_surge |
boolean | false |
buying_committee_size |
unsigned integer | 0 |
last_intent_activity_at |
datetime, nullable | |
health_score |
unsigned small integer | 70 |
health_status |
CustomerHealthStatus enum |
healthy |
last_health_calculated_at |
datetime, nullable | |
properties, owner_id, team_id |
as on contacts |
Helpers on Company:
isHealthy()andisAtRisk(): comparehealth_status.isTargetAccount():truewhenaccount_tieristier_1ortier_2.isSurging(): returnsintent_surge.became_mql_at,became_sql_at,owner(): as onContact.contacts(): the inverse ofContact::companies().whereDomain(string $domain)scope: normalizes the value withCompany::normalizeDomain()before matching.
Database defaults are not loaded into a model you just created. Call fresh() if you need health_status or lifecycle_stage right after create().
When you query through companies() or contacts(), qualify column names that also exist on the associations table, such as id. For example, use ->get()->modelKeys() instead of ->pluck('id').
Creating records
Use the actions rather than Model::create(). Only the actions normalize input and dispatch ContactCreated and CompanyCreated.
use Odden\Core\Actions\CreateCompanyAction;
use Odden\Core\Actions\CreateContactAction;
use Odden\Core\Enums\LeadStatus;
$company = app(CreateCompanyAction::class)->execute([
'name' => 'Acme Corp',
'domain' => 'https://Acme.com/',
'industry' => 'Manufacturing',
]);
$contact = app(CreateContactAction::class)->execute([
'first_name' => 'Jane',
'last_name' => 'Doe',
'email' => ' Jane@Acme.com ',
'lifecycle_stage' => 'lead',
'lead_status' => LeadStatus::New,
'properties' => ['annual_budget' => 150000],
], autoAssociateCompany: true);
$company->domain; // "acme.com"
$contact->email; // "jane@acme.com"
$contact->companies()->first(); // Acme Corp, association type "primary"CreateContactAction::execute(array $attributes, bool $autoAssociateCompany = false, bool $createCompanyIfMissing = false): Contact
- lowercases and trims
email. - converts a string
lifecycle_stagetoLifecycleStage. An invalid value throwsValueError. - creates the contact and dispatches
ContactCreated. - runs domain auto-association when
$autoAssociateCompanyistrueorodden-core.auto_associate_companiesistrue.
CreateCompanyAction::execute(array $attributes, bool $enrich = false): Company
- normalizes
domainwithCompany::normalizeDomain(): lowercased and trimmed, without the scheme, a leadingwww., a port, or any path or query, sohttps://www.Acme.com/aboutis stored asacme.com. - creates the company and dispatches
CompanyCreated. - runs enrichment when
$enrichistrueorodden-core.enrichment.auto_enrichistrue.
Domain auto-association
AutoAssociateContactCompanyAction links a contact to the company whose domain matches the contact's email domain:
use Odden\Core\Actions\AutoAssociateContactCompanyAction;
use Odden\Core\Models\Contact;
$contact = Contact::create(['email' => 'sam@mail.globex-corp.com']);
$company = app(AutoAssociateContactCompanyAction::class)->execute($contact, createCompanyIfMissing: true);
$company->name; // "Globex Corp"
$company->domain; // "globex-corp.com"execute(Contact $contact, bool $createCompanyIfMissing = false, string $associationType = 'primary'): ?Company does the following:
- Extracts the corporate domain with
ExtractCorporateDomainAction. It returnsnullif the email is invalid or uses a freemail domain. - Returns the company if the contact is already associated with a company that has this domain.
- Otherwise looks up a company by
domain. If the contact has ateam_id, only companies with the sameteam_idor no team match, and the team's own company is preferred. Among several matches the oldest wins. If one is found, associates the contact (as parent) with it using theprimaryassociation type. - If no company is found and
$createCompanyIfMissingistrue, creates one throughCreateCompanyAction, copying the contact'steam_idandowner_id, and associates it. The name is derived from the domain:globex-corp.combecomesGlobex Corp. Otherwise it returnsnull.
Through CreateContactAction this runs with createCompanyIfMissing as passed to that action, which defaults to false.
Corporate domain extraction
ExtractCorporateDomainAction::execute(string $email): ?string lowercases the address and validates it. It strips a leading www. and one common mail-server prefix (mail., email., smtp., webmail., mx., exchange., pop., imap.) when at least two dots remain. It returns null for freemail domains.
use Odden\Core\Actions\ExtractCorporateDomainAction;
$domains = app(ExtractCorporateDomainAction::class);
$domains->execute('ann@gmail.com'); // null
$domains->execute('ann@www.initech.io'); // "initech.io"Odden\Core\Support\FreemailDomains holds a built-in list of about 60 consumer providers (gmail.com, outlook.com, icloud.com, regional ISPs, and so on). Add your own with odden-core.freemail_domains. The values are compared exactly, so write them in lowercase:
// config/odden-core.php
'freemail_domains' => ['example-isp.net'],FreemailDomains::isFreemail(string $domain): bool checks both lists. FreemailDomains::all() returns only the built-in list.
Enrichment
EnrichCompanyAction::execute(Company $company, ?string $driverName = null): Company asks an enrichment driver about the company's domain and saves the result. It does nothing if the company has no domain, or the driver returns null or nothing besides a name. Otherwise it:
- sets
industryif it is empty, - merges
logo_url,tech_stack,employee_count_range,description,city,country,linkedin_url(whichever the driver returned) andenriched_atintoproperties, - saves normally, so model events fire and the changes are written to the property history,
- dispatches
CompanyEnrichedwith the raw driver data.
$company = app(CreateCompanyAction::class)->execute(
['name' => 'QuickPay', 'domain' => 'quickpay.com'],
enrich: true,
);
$company->industry; // whatever your driver returned
$company->getProperty('employee_count_range');The default heuristic driver (HeuristicEnrichmentDriver) makes no HTTP requests and guesses nothing: it returns a name derived from the domain, which EnrichCompanyAction doesn't use, so on its own it leaves companies unchanged. It returns a logo_url only when you set odden-core.enrichment.logo_url (env ODDEN_ENRICHMENT_LOGO_URL) to a logo service URL with a {domain} placeholder, such as https://www.google.com/s2/favicons?domain={domain}&sz=128; that sends each company's domain to the service, so it's off by default. For industry, size, and tech stack, register a driver backed by a real data source.
Earlier versions guessed the industry from keywords in the domain and saved it to industry, and set employee_count_range, tech_stack, and description to placeholder values. Those values stay on companies enriched before; clear them if you don't want them.
Custom drivers
Implement Odden\Core\Contracts\EnrichmentDriver and register it on the EnrichmentManager singleton, typically in a service provider's boot() method:
use Odden\Core\Contracts\EnrichmentDriver;
use Odden\Core\Support\Enrichment\EnrichmentManager;
class ClearbitDriver implements EnrichmentDriver
{
public function enrich(string $domain): ?array
{
// Call your provider. Return null when nothing is found.
return ['industry' => 'Software', 'city' => 'Berlin'];
}
}
app(EnrichmentManager::class)->extend('clearbit', fn () => new ClearbitDriver);extend() accepts a driver instance or a closure that returns one. Select it with ODDEN_ENRICHMENT_DRIVER=clearbit, or pass the name per call: app(EnrichCompanyAction::class)->execute($company, 'clearbit'). An unknown driver name throws InvalidArgumentException.
ODDEN_ENRICHMENT_DRIVER=heuristicCustomer health scores
CalculateCustomerHealthScoreAction::execute(Company $company): Company computes a score from 0 to 100, saves health_score, health_status, and last_health_calculated_at, and returns the refreshed company. Within Core, only merging companies calls it. The Filament package adds a button that runs it on demand. Nothing recalculates scores on a schedule, so schedule it yourself if you want them kept current.
use Odden\Core\Actions\CalculateCustomerHealthScoreAction;
$company = app(CalculateCustomerHealthScoreAction::class)->execute($company);
// With a note logged today and three associated contacts:
$company->health_score; // 95 (70 + 15 + 10)
$company->health_status; // CustomerHealthStatus::HealthyThe score starts at 70 and is adjusted as follows:
| Signal | Adjustment |
|---|---|
| Latest activity on the company within 14 days | +15 |
| Latest activity more than 14 and up to 30 days ago | +5 |
| Latest activity 60 or more days ago | -20 |
| No activities at all | -10 |
3 or more associated contacts (contacts()) |
+10 |
| No associated contacts | -10 |
A deal with status won / open / lost in the last 30 days |
+15 / +10 / -10 |
Each open high or urgent ticket |
-15, up to -30 |
| Each ticket with an SLA breach | -20, up to -40 |
| Average CSAT of 4.0 or more / 2.5 or less | +15 / -25 |
| No CSAT ratings, all tickets resolved, no breaches | +10 |
Only activities logged directly on the company count, not activities rolled up from associated contacts.
The deal and ticket signals use the company's deals and tickets relations. Core doesn't define them: Sales and Service register them with resolveRelationUsing(), and the action detects them with isRelation(), so it finds relations declared as methods or registered at boot. Without Sales the deal signals are skipped, and without Service the ticket signals are skipped. In that case the lowest possible score is 40, so a company can only reach AtRisk once at least one of those packages is installed. Deals count by their status (won, open, lost, from a string or backed enum). Tickets use status, priority, is_sla_response_breached, is_sla_resolution_breached, and csat_rating.
SummarizeTimelineAction uses the same relations for its deal and ticket counts.
The status is Healthy at 70 or above, Neutral from 40 to 69, and AtRisk below 40.
When a company moves into AtRisk from another status, the action also logs a pending task on the company, "Customer Churn Risk Alert: {name}", due in 24 hours.
Odden\Core\Enums\CustomerHealthStatus cases:
| Case | Value | label() |
color() |
badgeIcon() |
|---|---|---|---|---|
Healthy |
healthy |
Healthy | success |
heroicon-m-check-circle |
Neutral |
neutral |
Neutral | warning |
heroicon-m-minus-circle |
AtRisk |
at_risk |
At Risk | danger |
heroicon-m-exclamation-triangle |
Team scoping
Contacts and companies have a nullable team_id and the forTeam() scope:
$contacts = Contact::forTeam(1)->get();No global scope is applied. Every query returns all teams unless you add forTeam() yourself.