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()) withupdateQuietly(), so no model events fire and no history is recorded.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. It compares the stored value exactly, so it won't find a contact saved with mixed case; useContactLookupbelow for that.
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): ?Contactfirst looks for a contact stored with exactly the normalized address, which can use theemailindex. Only when there's none does it compareLOWER(TRIM(email)), which also matches older contacts saved with mixed case or spaces (this query can't use the index). 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.
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 and has isSubscribedToTopic() and getPreferenceCenterUrl(). Those read the marketing_topics and marketing_verification_token columns, which the Marketing package adds. They fail without it.
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: lowercases and trims the value 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
- lowercases and trims
domain, and strips a leadinghttp://orhttps://and any trailing/. It does not stripwww.or a path. - 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. 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. 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 with
saveQuietly(), so no model events fire and no property history is written, - dispatches
CompanyEnrichedwith the raw driver data.
$company = app(CreateCompanyAction::class)->execute(
['name' => 'QuickPay', 'domain' => 'quickpay.com'],
enrich: true,
);
$company->industry; // "Financial Services & FinTech"
$company->getProperty('employee_count_range'); // "11-50"The default heuristic driver (HeuristicEnrichmentDriver) makes no HTTP requests. It guesses from the domain name alone: the industry and tech stack come from keywords in the domain, employee_count_range is always 11-50, logo_url is a Google favicon URL, and description is generated text. Treat its output as placeholder data.
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.