vanilo/adjustments
Vanilo Adjustments provides a flexible adjustment system for Laravel e-commerce apps, letting you apply promotions, discounts, taxes, and other price modifications to orders and line items through a consistent, extensible API.
Install and wire up
composer require vanilo/adjustments
php artisan migrate
Laravel's package discovery registers the service provider automatically. The migration creates an adjustments table (polymorphic adjustable, a nullable adjuster morph, type, name, amount, and a data JSON column). Run php artisan vendor:publish first if the package exposes migrations/config you need to customize.
Read these files first — the module is small and mostly contracts:
src/Contracts/Adjustable.php — the interface your models must implement.src/Contracts/Adjustment.php — the adjustment contract.src/Traits/AdjustableTrait.php — what adjustments(), total(), and the sum helpers actually do.src/Models/Adjustment.php — fillable fields and casts.src/AdjustmentType.php (or equivalent) — the type constants (promotion, shipping, tax, fee, …).database/migrations/*_create_adjustments_table.php — column types and indexes.Make a model adjustable
use Vanilo\Adjustments\Contracts\Adjustable;
use Vanilo\Adjustments\Traits\AdjustableTrait;
class OrderItem extends Model implements Adjustable
{
use AdjustableTrait;
}
First use case — attach a discount to a line item and read the reconciled price:
$item->adjustments()->create([
'type' => AdjustmentType::PROMOTION,
'name' => 'Spring sale 10%',
'amount' => -1990, // minor units, negative = discount
'adjuster_type' => Promotion::class,
'adjuster_id' => $promotion->id,
'data' => ['rule' => 'pct_10', 'base' => 19900],
]);
$item->total(); // base price + all adjustments
$item->adjustmentTotal(); // sum of adjustments only
Persist adjustments, don't recompute silently. Adjustments are database rows, not virtual attributes. When a coupon is applied, insert a row; when the cart changes or the coupon is removed, delete and recreate. This gives you an audit trail and lets you explain "why is the total €42?" to a customer.
Use the polymorphic adjuster for provenance. Link each adjustment back to the entity that produced it (Promotion, Coupon, TaxRate, ShippingMethod). Then deleting a promotion can cascade or nullify its adjustments, and reporting can group by source without parsing name strings.
Signed amounts, one convention. Decide once: discounts negative, fees/taxes/shipping positive (or all positive with the type carrying the sign). Whatever you choose, enforce it in a single factory/service (AdjustmentService::discount(), ::fee(), ::tax()) rather than at every call site — mixed conventions are the #1 source of doubled or inverted totals.
Layer order-level and item-level adjustments. Compute line totals from item adjustments first, then apply order-level adjustments (shipping, order-wide coupons) on the subtotal. Do this in one calculator class so grandTotal() never drifts between the cart page, checkout, and the invoice.
Reconcile on cart mutations. A typical flow:
DB::transaction(function () use ($cart) {
$cart->items->each->adjustments()->delete();
$cart->adjustments()->delete();
$this->promotionEngine->apply($cart); // creates fresh rows
});
Store the "why" in data. Percentages, matched rule IDs, original prices, and rounding remainders belong in the JSON column. It survives schema changes and makes support tickets tractable.
Test with direct row creation. You rarely need the full promotion engine to test totals — build adjustments in factories and assert on total() / adjustmentTotal().
price changes after an adjustment is created, stale percent-based amounts won't self-correct. Store the computed amount (not just the percentage) or explicitly recalc on price change.total() vs adjustmentTotal(). First is base + adjustments; second is the delta only. Mixing them up produces totals that look plausible but are off by the base price. Assert both in tests.amount column type. Prefer integer minor units or decimal(15,4) with bcmath; floats will bite you on multi-item sums and tax rounding.adjustments() is a lazy morphMany. Eager-load with('adjustments') anywhere you display totals in a table, or you'll fire a query per row.deleting observer or a scheduled cleanup, and consider a FK/cascade on adjustable_id if your schema allows it.adjuster. Manual adjustments (gift cards, corrections) legitimately have no adjuster — always null-check before calling $adjustment->adjuster->name.data is cast to array. Mutating nested keys requires reassignment ($a->data = [...] then save()); in-place $a->data['x'] = 1 may not persist depending on the cast.RefreshDatabase against a fresh schema.Adjustment implementation via concord()->registerModel(...). If you need extra columns or behavior, extend rather than editing vendor files — but verify the binding exists in this version's service provider.$model->adjustments->groupBy('type')->map->sum('amount') — it isolates which adjustment category is misbehaving far faster than stepping through the promotion engine.How can I help you explore Laravel packages today?