Paid add-on. Marketing is licensed separately from the open-source packages. See pricing
Attribution and closed-loop reporting
Attribute pipeline and revenue to campaigns, calculate closed-loop marketing ROI, and analyze multi-step conversion funnels.
Three actions report on how marketing turns into revenue. GetCampaignAttributionAction reports on a single campaign, CalculateClosedLoopMetricsAction reports on all marketing activity, and AnalyzeConversionFunnelAction counts how many people reach each step of a funnel you define. All of them run their queries when called; nothing is stored or scheduled.
Revenue figures come from deals in getodden/crm-sales. Without that package installed, the revenue and deal values are zero. A deal counts as influenced by marketing when it's associated with a contact that marketing reached.
Attribution models
Odden\Marketing\Enums\AttributionModel selects how a deal's credit is split across the marketing touches that led to it:
| Case | Value |
|---|---|
FirstTouch |
first_touch |
LastTouch |
last_touch |
Linear |
linear |
UShaped |
u_shaped |
WShaped |
w_shaped |
TimeDecay |
time_decay |
label() returns a display name such as U-Shaped Attribution (40/40/20 Weighting).
Touches and weights
A touch is either a campaign recipient's first open or click (at the time of that first engagement), or a form submission whose utm_campaign matches a campaign (see Campaign attribution). Odden\Marketing\Services\AttributionCalculator collects the touches of all the contacts associated with a deal, across every campaign, puts them in time order, and splits the deal's credit across them. The weights of a deal always add up to 1, so no model creates or loses revenue; it only decides which campaign gets it. A touch counts whenever it happened, before or after the deal closed.
| Model | Split |
|---|---|
FirstTouch |
100% to the first touch |
LastTouch |
100% to the last touch |
Linear |
Equal shares |
UShaped |
40% first, 40% last, 20% shared by the touches in between (50/50 with two touches) |
WShaped |
30% first, 30% the touch that converted the lead (the first form submission between the first and last touch, else the middle touch), 30% last, and 10% shared by the rest (if there are no others, the three share it equally; 50/50 with two touches) |
TimeDecay |
Each touch is worth half as much for every odden-marketing.attribution.time_decay_half_life_days (default 7, env MARKETING_ATTRIBUTION_HALF_LIFE_DAYS) before the deal's last touch, normalized to 1 |
With a single touch, every model gives it all of the credit. A campaign's credit for a deal is the sum of the weights of its touches.
Campaign attribution
use Odden\Marketing\Actions\GetCampaignAttributionAction;
use Odden\Marketing\Enums\AttributionModel;
$report = app(GetCampaignAttributionAction::class)->execute($campaign, AttributionModel::UShaped);
$report['won_revenue']; // won deal amounts, unweighted
$report['attributed_won_revenue']; // this campaign's share of the won revenue under the model
$report['roi_percentage'];execute(Campaign $campaign, AttributionModel $model = AttributionModel::Linear): array collects two groups of contacts:
- Leads: contacts with a form submission whose
utm_campaignmatches the campaign's UTM slug,Campaign::utmCampaignSlug():Str::slug()of itsutm_campaignfield, or of its name when that is empty. Both sides are compared as slugs, soSpring Sale 2026matches a campaign whoseutm_campaignisspring-sale-2026. - Engaged contacts: the campaign's recipients who opened or clicked.
It then looks at every deal associated with any of these contacts, on either side of the association: won deals go into won revenue and open deals into pipeline, each counted once. The attributed values are the deal amounts times this campaign's credit for that deal (see above).
The same slug is what UTM auto-tagging writes into the campaign's links, so a tracked link and the matching form submission always agree.
The returned array:
| Key | Meaning |
|---|---|
campaign_name, attribution_model |
Echoed inputs. |
leads_count |
Distinct lead contacts. |
engaged_contacts_count |
Distinct engaged recipients. |
deals_count |
Distinct associated deals, any status. |
budget |
The campaign's budget, or null. |
actual_cost |
actual_spend, falling back to actual_cost, else 0. |
pipeline_value, won_revenue |
Unweighted open and won deal amounts. |
attributed_pipeline_value, attributed_won_revenue |
This campaign's weighted share of the open and won amounts. |
net_profit |
attributed_won_revenue − actual_cost. |
roi_percentage |
net_profit / actual_cost × 100, or 0 without a cost. |
cost_per_lead |
actual_cost / leads_count, or 0 without leads. |
For example, a contact reached by three campaigns (touches 30, 15 and 1 days ago) with a $100,000 won deal gives the first and last campaign 40,000 each and the middle one 20,000 under UShaped. For a campaign with one lead whose $5,000 won deal has no other touches, attributed_won_revenue is 5000 under any model; with $1,000 actual_spend, roi_percentage is 400 and cost_per_lead 1000.
Budget fields such as budget, actual_spend, and target_revenue are set on the campaign; see campaigns.
Closed-loop metrics
use Odden\Marketing\Actions\CalculateClosedLoopMetricsAction;
use Odden\Marketing\Enums\AttributionModel;
$metrics = app(CalculateClosedLoopMetricsAction::class)->execute(AttributionModel::Linear);execute(?AttributionModel $model = null): array looks at every contact that marketing reached: any campaign recipient (whether or not they engaged) and any contact with a form submission. Influenced deals are the deals associated with those contacts, in either direction.
Total marketing spend is the sum of all campaigns' actual_spend, or, if that sum is 0, the sum of actual_cost.
| Key | Meaning |
|---|---|
total_influenced_pipeline |
Sum of all influenced deal amounts, any status. |
total_closed_won_revenue |
Sum of won influenced deals. |
total_marketing_spend |
See above. |
blended_cac |
Spend / won deals. |
cost_per_lead |
Spend / marketing-reached contacts. |
marketing_roi_percentage |
(won revenue − spend) / spend × 100, one decimal. |
won_deals_count, open_deals_count |
Influenced deals by status. |
marketing_win_rate |
Won / (won + lost) × 100, one decimal. |
average_sales_cycle_days |
Average days from the first associated contact's created_at to the deal's closed_at (or updated_at), minimum 1 per deal. |
top_campaigns |
Up to five campaigns with delivered emails and influenced pipeline, sorted by won revenue. Each has name, won_revenue, pipeline_influenced, spend, roi_percentage, and, when you pass a model, attributed_won_revenue (the campaign's weighted share of the won revenue). |
attribution_model |
The model value, or null. |
attributed_closed_won_revenue, attributed_pipeline |
Without a model, the same as the totals. With one, the won revenue and pipeline of the deals that have at least one marketing touch (a deal's credit adds up to the whole deal whatever the model, so the models differ per campaign, not in this total). |
attributed_roi_percentage |
ROI using the attributed won revenue. |
When there are no marketing contacts or influenced deals, every value except the spend (and cost_per_lead, when there are contacts) is 0. top_campaigns finds deals associated with the campaign's contacts in either direction, like the totals.
Conversion funnels
Odden\Marketing\Actions\AnalyzeConversionFunnelAction counts each step of a funnel within a date range:
use Odden\Marketing\Actions\AnalyzeConversionFunnelAction;
$funnel = app(AnalyzeConversionFunnelAction::class)->execute(
steps: [
['name' => 'Viewed pricing', 'type' => 'page_view', 'path' => '/pricing'],
['name' => 'Requested a demo', 'type' => 'form_submission', 'form_slug' => 'request-a-demo'],
['name' => 'Closed won', 'type' => 'deal_won'],
],
startDate: now()->subDays(90),
);
foreach ($funnel['steps'] as $step) {
echo "{$step['name']}: {$step['count']} ({$step['conversion_rate']}% of previous step)";
}execute(array $steps, ?CarbonInterface $startDate = null, ?CarbonInterface $endDate = null): array defaults to the last 30 days (from the start of the day 30 days ago to the end of today). Step types and what they count:
type |
Counts | Filters |
|---|---|---|
page_view or page_visit |
Distinct visitor sessions with a matching pageview | path, matched as a substring |
form_submission |
Form submissions | form_id or form_slug |
behavioral_event |
Distinct contacts with a custom event | event_name |
campaign_click |
Distinct recipient emails that clicked | campaign_id |
contact_created |
Contacts created | none |
deal_created |
Deals created (0 without getodden/crm-sales) |
none |
deal_won |
Deals won, by closed_at (0 without getodden/crm-sales) |
none |
Each step is counted independently over the whole date range; the action doesn't follow individual people from one step to the next. An unknown type counts 0.
The result has steps (each with index, name, type, count, conversion_rate from the previous step, dropoff_count, dropoff_rate, and overall_conversion_rate from the first step), total_top_of_funnel, total_bottom_of_funnel, overall_funnel_conversion_rate, and time_window_days. A step that follows a step with a count of 0 shows a 100% conversion rate.