Saving & comparing units
Saving and comparing units is the one cross-persona feature in the platform: the shopper, broker and sales-man apps all ship the same “save a unit” (wishlist/favorite) and “compare units side-by-side” surfaces, backed by the same controllers, actions and pivot table. The behaviour splits into two very different mechanics:
- Saved units are persisted server-side in a polymorphic pivot (
unit_user), one row per(user, unit). - Compare is stateless server-side — the selection lives client-side (a sessionStorage-backed
compare context, max 6); the API just resolves a list of
unit_idson every call. Only the export turns that into a saved artifact (a spreadsheet).
Verified against current backend code (UnitBaseController, UserSavedUnitsBaseController, the
unit_user migrations, and the persona route files).
What the user sees
Section titled “What the user sees”The saved-units (wishlist) page lists every unit the signed-in user has favorited — here the shopper app and the sales-man app, the same feature rendered per persona:


How it fits together
Section titled “How it fits together”sequenceDiagram
participant App as App (shopper/broker/sales-man)
participant API as Backend
participant DB as unit_user pivot
participant CRM as crms-middleware
App->>API: POST user/saved-units/{unit}
API->>DB: syncWithoutDetaching (idempotent)
App->>API: GET user/saved-units
API->>DB: join on user_id + user_type, only available
API-->>App: paginated saved units
Note over App: compare list lives client-side (sessionStorage, max 6)
App->>API: GET units/compare?unit_ids[]=...
API->>DB: load units by id (AccessibleUnit-gated)
API-->>App: side-by-side unit data (local only)
App->>API: GET units/compare/export?unit_ids[]=...¤cy_code=...
loop per unit
API->>CRM: live payment-terms read
end
API-->>App: spreadsheet download (xlsx)The saved-units storage model
Section titled “The saved-units storage model”Saves live in unit_user — a pivot that became polymorphic so a single table can back every
persona:
- The original table (
2022_12_05_181944_create_unit_user_table.php:15-19) had justid,user_id(FK →users),unit_id(FK →units). - A later migration (
2025_01_28_191115_add_morph_user_to_unit_user.php:19-36) dropped theuser_idFK, added auser_typestring column plus a composite index[user_id, user_type]andtimestamps, then backfilleduser_typefor existing broker/user rows. That turns the plain many-to-many into a morph pivot: shopper, broker and sales-man rows coexist keyed by(user_id, user_type). - On
User, the relation issavedUnits()=morphToMany(Unit::class, 'user', 'unit_user')(app/Models/User.php:74-77). The pivot-as-modelUnitUserhasbelongsTo(Unit)(app/Models/UnitUser.php:13) and amorphTouser(app/Models/UnitUser.php:18).
Step by step
Section titled “Step by step”-
Save a unit —
POST .../user/saved-units/{unit}→UserSavedUnitsController@addSavedUnit(app/Http/Controllers/Api/Base/UserSavedUnitsBaseController.php:14). The action runs$user->savedUnits()->syncWithoutDetaching([$unitId])(app/Actions/Unit/AddUserSavedUnit.php:10) — idempotent, so saving the same unit twice writes one row and never errors. The{unit}binding must resolve to a published + available unit for that persona (see the endpoint table). -
Remove a save —
DELETE .../user/saved-units/{unit}→UserSavedUnitsController@removeSavedUnit(app/Http/Controllers/Api/Base/UserSavedUnitsBaseController.php:23), running$user->savedUnits()->detach($unitId)(app/Actions/Unit/RemoveUserSavedUnit.php:9). A no-op (no error) if the unit was not saved. -
List saved units —
GET .../user/saved-units→getSavedUnits→GetUserSavedUnits(app/Actions/Unit/GetUserSavedUnits.php:40-59). Joinsunit_useronuser_id+user_type, keeps onlyavailable()units, applies optionaldestinations/unit_numberfilters (app/Http/Requests/Unit/SavedUnitRequest.php:27-32), re-applies the persona’s permission scope (assigned units / allowed projects,app/Actions/Unit/GetUserSavedUnits.php:64-77), and paginates (default 6 per page). -
Build a compare set (client-side) — the app keeps the selected
unit_idsin a sessionStorage compare context (UNIT_COMPARE_CONTEXT/CompareUnitsProvider), capped at 6. Nothing is written server-side at this stage. -
Compare —
GET .../units/compare?unit_ids[]=...→UnitController@compare→UnitBaseController::compare(app/Http/Controllers/Api/Base/UnitBaseController.php:46). Validated byCompareRequest:unit_idsrequired array,min:1,max:6, each id gated by theAccessibleUnitrule (app/Http/Requests/Unit/CompareRequest.php:27-31).GetUnitsByIdeager-loads images/type/project/destination/country/phase/facilities (app/Actions/Unit/GetUnitsById.php:12-22) and returnsCompareUnitResource::collection— a purely local read, no CRM. -
Export the comparison —
GET .../units/compare/export?unit_ids[]=...¤cy_code=...→UnitController@compareExport(app/Http/Controllers/Api/Base/UnitBaseController.php:53).CompareExportRequestadds a requiredcurrency_code(app/Http/Requests/Unit/CompareExportRequest.php:29-33).ExportCompareUnitsServicere-loads the units, then for each unit makes a live CRMpayment-termsread and formats it per country (Egypt/Oman/Montenegro), converts every price intocurrency_codeviaCurrencyRatesService, and streams an.xlsxback throughMaatwebsite\Excel(app/Services/ExportCompareUnitsService.php:16-41), filenameUnits Compare Details <datetime>.xlsx.
Per-persona endpoints
Section titled “Per-persona endpoints”{p} is the persona API prefix. All routes require the persona’s auth guard.
| Capability | Shopper | Broker | Sales-man |
|---|---|---|---|
| Compare | GET {p}/units/compare (shopper.php:56) |
GET {p}/units/compare (broker.php:89) |
GET {p}/units/compare (sales-man.php:28) |
Compare export (.xlsx) |
GET {p}/units/compare/export (shopper.php:58) |
GET {p}/units/compare/export (broker.php:90) |
not available |
| List saved | GET {p}/user/saved-units (shopper.php:131) |
GET {p}/user/saved-units (broker.php:92) |
GET {p}/user/saved-units (sales-man.php:42) |
| Save | POST {p}/user/saved-units/{publishedAvailableUnitShopper} (shopper.php:132) |
POST {p}/user/saved-units/{publishedAvailableUnitBroker} (broker.php:120) |
POST {p}/user/saved-units/{publishedAvailableUnitSalesMan} (sales-man.php:44) |
| Remove | DELETE {p}/user/saved-units/{publishedUnitShopper} (shopper.php:133) |
DELETE {p}/user/saved-units/{savedUnit} (broker.php:144) |
DELETE {p}/user/saved-units/{savedUnit} (sales-man.php:49) |
| Auth guard | Passport + user-provider:shoppers |
Sanctum + user-provider:brokers |
Sanctum + ability:ACCESS_SALES_MAN_API |
The {published…Unit…} route-model bindings enforce persona visibility before the controller
runs; broker and sales-man add a resource-visibility / salesman-resource-visibility middleware on
save/remove (broker.php:118-146, sales-man.php:46-51).
Gotchas
Section titled “Gotchas”-
Sales-man has compare but not compare-export. Only shopper and broker expose
units/compare/export; the sales-man route file has no export route (sales-man.php:28has compare only). The sales-manunits/exportroute (sales-man.php:27) is a different feature — a whole-listing units report, not the compare spreadsheet. -
Saved is persisted, compare is not. A save is a durable
unit_userrow. The compare selection is client-only state (sessionStorage) — refresh or a new device starts empty. Do not look for a “compare list” table; there isn’t one. -
A save can silently vanish from the list.
getSavedUnitsfilters toavailable()and re-applies permissions (GetUserSavedUnits.php:48,64-77). If a unit goes unavailable or leaves the user’s project/unit scope, it drops out of the wishlist response even though theunit_userrow still exists. It is filtered, not deleted. -
Export is N live CRM calls.
compareExportcallspayment-termsonce per unit (ExportCompareUnitsService.php:28-38), so exporting 6 units is 6 CRM round-trips — slower and more failure-prone than compare (which is local-only). A unit whosepayment-termsread is non-200 is written with an emptypayment_terms: []rather than failing the whole export (ExportCompareUnitsService.php:59-61). -
It is
.xlsx, not CSV. The export streams an Excel workbook viaMaatwebsite\Excel(UnitBaseController.php:63,68), namedUnits Compare Details <datetime>.xlsx. Prices are converted into the requestedcurrency_code; a missing/invalidcurrency_codefails validation (CompareExportRequest.php:32). -
Max 6 is enforced twice. Both the client compare context and the server
CompareRequest/CompareExportRequestcapunit_idsat 6 (CompareRequest.php:28). A 7th id is rejected server-side even if the client somehow sends it. -
user_typeis the isolation key. Never queryunit_userbyuser_idalone — the same numeric id belongs to a different persona in a different table. Always pair it withuser_type(GetUserSavedUnits.php:49-50).
