Deals
Create deals, link them to contacts and companies, move them through stages, close them as won or lost, and manage line items.
An Odden\Sales\Models\Deal is an opportunity sitting in one stage of one pipeline. Deals have an amount, a status (open, won, or lost), an owner, and a full stage history. They are soft-deletable and use Core's activity, association, custom property, property audit, and team traits, so everything in Core concepts applies to them.
Attributes
| Attribute | Type | Notes |
|---|---|---|
pipeline_id |
int | |
stage_id |
int | |
name |
string | |
amount |
decimal(15,2) | Defaults to 0.00. Overwritten from line items when you use products. |
currency |
string(3) | Database default USD. |
status |
DealStatus |
Database default open. |
expected_close_date |
date, nullable | Used by quotas and the health score. |
closed_at |
datetime, nullable | Set when the deal enters a closed stage. |
lost_reason |
string, nullable | Free text; the LostReason enum values are suggested. |
lost_notes |
string, nullable | |
properties |
array, nullable | Custom properties. |
owner_id |
int, nullable | The user who owns the deal (your configured user model). |
team_id |
int, nullable |
Odden\Sales\Enums\DealStatus has Open (open), Won (won), and Lost (lost), with label(), color(), isOpen(), isWon(), and isLost() helpers.
Odden\Sales\Enums\LostReason has Price, Competitor, FeatureGap, Timing, Unresponsive, and Other, with label() and color(). The lost_reason column is a plain string, so pass LostReason::Competitor->value (or any other string).
Creating a deal
use Odden\Sales\Enums\DealStatus;
use Odden\Sales\Models\Deal;
$deal = Deal::create([
'pipeline_id' => $pipeline->id,
'stage_id' => $pipeline->defaultStage()->id,
'name' => 'Acme renewal',
'amount' => 12000,
'status' => DealStatus::Open,
'expected_close_date' => now()->addMonth(),
'owner_id' => $user->id,
]);Pass status explicitly. The column defaults to open in the database, but the model has no attribute default, so a deal created without status has a null status in memory until you call $deal->refresh(). Methods such as isRotten() and getHealthScore() need the status to be set.
The currency column defaults to USD at the database level. The odden-sales.default_currency config value is not applied to new deals, so set currency yourself if you sell in another currency.
Creating a deal does not write a stage history row or run stage automations. Both happen only when the deal changes stage.
Contacts and companies
Deals link to contacts and companies through Core associations, with the deal as the parent record:
$deal->associateWith($contact);
$deal->associateWith($company);
$deal->contacts; // Collection of Odden\Core\Models\Contact
$deal->companies; // Collection of Odden\Core\Models\Company
$contact->deals; // Added to Contact by the sales service provider
$company->deals; // Added to Company by the sales service providerMoving between stages
Use moveToStage() on the deal, which calls Odden\Sales\Actions\ChangeDealStageAction:
public function moveToStage(
PipelineStage $stage,
int|string|null $userId = null,
?string $lostReason = null,
?string $lostNotes = null,
): Deal$deal->moveToStage($proposal, $user->id);Inside one database transaction, the action:
- Runs the target stage's stage automations. A failed requirement throws
StageRequirementExceptionand nothing is changed. - Closes the open stage history row: sets
exited_atandduration_in_stage_seconds. - Sets
stage_id, andpipeline_idfrom the target stage, so you can move a deal into another pipeline by passing a stage from that pipeline. - Sets
statustowonorlostandclosed_atto now when the stage is closed won or closed lost. For any other stage it setsstatustoopenand clearsclosed_at, so moving a closed deal back to an open stage reopens it. - Clears
lost_reasonandlost_noteswhen the target stage is not closed lost, unless you pass new values. - Writes a new
DealStageHistoryrow. Itsuser_idis$userId, or the authenticated user if you passnull. - Dispatches
DealMovedStage, plusDealWonorDealLostfor closed stages. See Events. - When the deal is won or lost, sets every active sequence enrollment of the deal's associated contacts to
unenrolled. See Sequences.
The action does not check whether the deal is already in the target stage; moving a deal to its current stage records another history row and dispatches the events again.
Won and lost
use Odden\Sales\Enums\LostReason;
$deal->markLost(LostReason::Competitor->value, $user->id, 'Went with a cheaper vendor.');
$deal->markWon($user->id);markWon(int|string|null $userId = null)moves the deal to the pipeline's first stage withis_closed_won.markLost(?string $reason = null, int|string|null $userId = null, ?string $notes = null)moves the deal to the first stage withis_closed_lostand stores the reason and notes.
Both throw a RuntimeException (for example, Pipeline [New Business] has no closed won stage defined.) if the pipeline has no such stage.
Query scopes: Deal::query()->open(), ->won(), and ->lost().
Stage history
Every stage change writes an Odden\Sales\Models\DealStageHistory row with from_stage_id, to_stage_id, user_id, entered_at, exited_at, and duration_in_stage_seconds. The current stage's row has exited_at = null.
$history = $deal->stageHistory()->with(['fromStage', 'toStage', 'user'])->get();
foreach ($history as $entry) {
$entry->toStage->name;
$entry->duration_in_stage_seconds; // null for the current stage
}stageHistory() is ordered by entered_at, newest first. $deal->daysInCurrentStage() uses the newest row's entered_at, falling back to the deal's created_at when there is no history.
Products
Odden\Sales\Models\DealProduct is a soft-deletable line item: deal_id, name, sku, description, unit_price, quantity, discount_percent, and sort_order. $deal->products returns them ordered by sort_order.
total_price is calculated on every save as quantity × unit_price × (1 − discount_percent / 100), rounded to two decimals and never below zero. Any value you pass is overwritten.
Always pass quantity. The column defaults to 1, but the default is only applied by the database after total_price has been calculated, so a product created without quantity gets a total_price of 0.00.
Saving, deleting, or restoring a product recalculates the deal's amount as the sum of its products' total_price (via SyncDealAmountAction, which uses updateQuietly, so no model events or property audits fire for the change).
use Odden\Sales\Models\DealProduct;
DealProduct::create([
'deal_id' => $deal->id,
'name' => 'Platform licence',
'sku' => 'PLAT-001',
'unit_price' => 1000,
'quantity' => 10,
'discount_percent' => 15,
]); // total_price 8500.00
DealProduct::create([
'deal_id' => $deal->id,
'name' => 'Onboarding',
'unit_price' => 2500,
'quantity' => 1,
]);
$deal->fresh()->amount; // "11000.00"Once a deal has products, treat amount as derived: a manual amount is replaced the next time a product changes. Calling $deal->syncAmountFromProducts() on a deal with no products sets its amount to 0.00.
Events
All events are in Odden\Sales\Events and use Dispatchable and SerializesModels.
| Event | Properties | Dispatched when |
|---|---|---|
DealMovedStage |
Deal $deal, ?PipelineStage $fromStage, PipelineStage $toStage, int|string|null $userId |
Every stage change. |
DealWon |
Deal $deal, int|string|null $userId |
The deal enters a closed won stage. |
DealLost |
Deal $deal, ?string $reason, int|string|null $userId |
The deal enters a closed lost stage. |
The events are dispatched inside the stage-change transaction. $userId is the value you passed, not the authenticated-user fallback used for the history row, so it is null when the change came from quote acceptance. If you queue listeners, consider ShouldQueueAfterCommit so they don't run before the transaction commits.
use Odden\Sales\Events\DealWon;
use Illuminate\Support\Facades\Event;
Event::listen(function (DealWon $event): void {
// Start onboarding for $event->deal
});