You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
User invitations. A CRM admin can now invite someone by email instead of creating the account themselves and handing over a password out of band. the Invite button on /crm/users sends a UserInvitationNotification carrying an accept link; the invitee follows it and either signs in as an existing host user or sets a password and gets a new one, landing with the CRM role and team the inviter chose. The old laravel-crm.users.sendinvite route did none of this and is gone — see Removed
New crm_user_invitations table and UserInvitation model, route-keyed on a 64-character code, with isPending() / isExpired() / isAccepted() / isValid() state predicates and an observer stamping external_id and code on creating
UserInvite Livewire form on the users index, offering only the roles the caller may hand out (Role::assignableBy() — see Security)
Public accept routes users/invitations/{code}/accept (GET and POST), deliberately outside both auth.laravel-crm and the CRM-access check — a logged-out invitee has to reach them, and an invited user carries crm_access = 0 until they accept
A Pending Invitations tab on the users index alongside Registered Users, with resend (stamping last_sent_at) and revoke (soft delete) row actions
Five PDF templates, pickable per document type and per record. Invoices, orders, quotes, purchase orders and deliveries render through one of modern (the new default), classic, bold, compact or professional (PdfTemplateRegistry::SLUGS) rather than the single hardcoded layout each had before
Settings → Templates sets the default per document type, with a tab per type, packaged thumbnails and a live full-page preview rendered from sample data through TemplatePreviewController
A PDF template select on each of the five document create / edit forms pins a template to that record; the blank option means "follow the Settings default". Every later download, email send and portal render of that record resolves through PdfTemplateRegistry::viewForModel(), so all 12 render call sites agree on one answer
A host that customised its PDFs the pre-picker way — by publishing and editing resources/views/vendor/laravel-crm/invoices/pdf.blade.php and its siblings — keeps that view; see Fixed
Every team gets its own CRM lookup data and pipelines. On a teams install the pipelines, stages, labels, tax rates, industries and the three type lookups were global, so /leads/create rendered against an empty per-team pipeline and no team could tailor its own stages. The one-time db_update_1201 backfill — run by laravelcrm:update, laravelcrm:install and laravelcrm:v2 — copies the lot to every pre-existing team, then re-points existing records at that team's stages, rewriting pipeline_stage_id on crm_leads, crm_deals, crm_quotes, crm_orders, crm_invoices, crm_deliveries and crm_purchase_orders. Matching is by stage name within the same pipeline model, only the backfilled team's rows are touched, a stage with no per-team counterpart is left alone, and there is no down(). Take a database backup before upgrading a teams install — see docs/upgrading.md
A host-team switcher in the CRM header. Under config('laravel-crm.teams') the app layout gained a dropdown listing the teams the signed-in user belongs to, with a per-team POST switch form and a check mark on the current one, so an operator no longer has to leave the CRM to change team. It works on hosts without Jetstream — team detection falls back to the user's ownedTeams() relation. Tenant teams are labelled Enterprise
+ New team creates the team from inside the CRM. New HostTeamController and a quick-create form write the row through the host application's own team model and switch the user onto it, so a host whose teams.create route sits behind its own middleware (Jetstream's hasNoTeam, for instance) no longer blocks the link. New host_team_model config key (LARAVEL_CRM_HOST_TEAM_MODEL) overrides the auto-detection
Tasks take an optional start date and time.start_at on crm_tasks, surfaced on the create / edit form, the inline task item, the related-task list and the show page, so a task can describe a window rather than only a deadline
Role and CRM-access filters on the users index, replacing the owner and label filters the page had inherited from the CRM entity indexes — neither matched anything a user row actually carries. See Changed for the query-string break
A shared tabs bar across the integration settings pages. Xero and ClickSend each rendered inside the general settings sidebar shell, so moving between them meant navigating back out to Settings. They now share layouts/partials/nav-integrations.blade.php, and the two items are dropped from the settings sidebar
Persian (fa) translations — a full resources/lang/fa set alongside the existing en, en_au and en_gb
Performance and recovery alerts stop re-firing on every monitor check.perf_notified_at and recovered_notified_at on crm_monitors rate-limit the slow-response and recovered notifications the way notified_at already did for downtime — a monitor sitting just over its performance threshold sent one email per check, so a 5-minute monitor mailed its owner 288 times a day. Both windows are configurable (monitoring.perf_alert_rate_limit_minutes and monitoring.recovered_alert_rate_limit_minutes, 60 minutes each by default), and a recovery alert is only sent when a down or slow alert actually preceded it
monitoring.max_response_bytes (LARAVEL_CRM_MONITORING_MAX_RESPONSE_BYTES, 5 MB) caps the response body MonitorCheckService will read, so a monitored endpoint streaming an unbounded response cannot exhaust the queue worker's memory
Every foreign key on an API write is now validated against the caller's team. New ScopedExists rule builds an exists rule constrained to the current team when laravel-crm.teams is on. The bare Laravel exists rule queries the database directly and so bypasses BelongsToTeamsScope, which let an authenticated caller reference another tenant's external_id and have it accepted. Applied across every Store* / Update* request for leads, deals, quotes, orders, invoices, people and products; a table with no team_id (labels, lead sources) falls back to a bare exists rather than producing a SQL error, and a caller holding no current team is failed rather than passed. OwnerInCurrentTeam was rewritten onto the same footing. Cross-team ids now present as a 422
POST /api/crm/v2/auth/token is throttled per account as well as per IP — api.token_attempts_per_account (5) failed attempts per api.token_attempts_decay_seconds (600), so credential stuffing spread across many IPs against one email address no longer gets unlimited attempts. Exhausting it returns 429. See docs/api.md
php artisan laravelcrm:upgrade — the safe, database-free half of an upgrade: republishes built assets, prunes stale content-hashed build output, publishes Flasher assets and clears cached config/routes/views. It never opens a database connection and never prompts, so it is safe to run unattended from a composer hook on a production box mid-build. laravelcrm:install now adds @php artisan laravelcrm:upgrade --ansi to the host application's post-autoload-dump scripts, so every later composer install / composer update republishes assets on its own — the same hook Filament uses, and for the same reason (it fires on install as well as update, so a production composer install --no-dev is covered). Existing installs add the line once by hand; see docs/upgrading.md
Pruning matters because Vite output is content-hashed and vendor:publish --force adds but never removes, so a host accumulated one app-<hash>.js per release it had ever installed while manifest.json named exactly one of them. Only top-level files in public/vendor/laravel-crm/assets with no counterpart in the package are removed — img/, fonts/, libs/ and css/ are untouched
New migrations no longer need publishing. They ship as real .php files in database/updates, loaded with loadMigrationsFrom, so they reach every host through a plain php artisan migrate with no publish step, no hand-picked order number and no entry in the service provider. The existing 134-entry .stub publish array is frozen and still published, so existing hosts are entirely unaffected and keep the filenames they already have. loadMigrationsFrom previously pointed at database/migrations, which holds only .stub files and is therefore invisible to the migrator's *_*.php glob — the call was inert
All seven migrations added in this release now take that route, where they had been left behind as .stub files with publish-array entries 135–141 — the exact arrangement this change exists to retire. A host following the documented composer update && php artisan migrate would have received none of them and then hit Column not found on the first task save (start_at) or PDF template choice. SystemCheckService already scanned database/updates for unrun migrations; it simply had nothing to find
A db_version marker, stamped by laravelcrm:install and by laravelcrm:update on success, so the app can tell that the code is ahead of the database. The existing version setting cannot carry this: Http/Middleware/Settings overwrites it with config('laravel-crm.version') on the first web request after a deploy, so it always reads as current no matter what the database has had applied
upgrade_guide_url config key (LARAVEL_CRM_UPGRADE_GUIDE_URL), defaulting to https://laravelcrm.com/docs/2.x/upgrading. Every "Upgrade guide" link in the CRM — both on the updates page and the one in the system check's upgrade-required banner — now points there instead of at the GitHub repository. docs_url is unchanged and still carries the "View version X details" link, which is a release-notes link rather than an install one
Both update commands are now printed in the UI. The updates page gained a "How to update" card showing composer update venturedrake/laravel-crm and php artisan laravelcrm:update, and flags a database that is behind the code; the system check banner now shows the command to run rather than only linking to a page about it
laravelcrm:sample-data now covers this release's new surfaces, so a demo or dev install shows them rather than leaving them empty: a start date and time on ~40% of tasks, a pdf_template across all five document types (the majority left null, so the "follow the Settings default" path is represented too), fractional quantities on ~30% of line items, and crm_user_invitations rows in each of the pending, expired and accepted states. The seeder also gained its first execution coverage — tests/Feature/Seeders/SampleDataSeederTest.php runs it end to end at 2% scale and asserts money stored as integer cents, quantities surviving the copy from quote to order to invoice and delivery, and every document sitting in a stage of its own pipeline
Changed
Version bumped to 2.4.0.config/package.php still read 2.3.0, which both SystemCheckService::normalisedVersion() and the db_version marker key off — so the update banner and the "database is behind the code" check would both have compared this release against the previous one's number
Breaking (API): subtotal and total are rejected on quote / order / invoice writes. Both are computed from line_items, discount, tax and adjustments. They had been dropped from the validation rules, so a client still sending its own authoritative totals was silently ignored and got recomputed numbers back with no error — the kind of break that surfaces weeks later when figures stop reconciling. They are now prohibited with a message pointing at line_items, so it presents as a 422 naming the cause. prohibited passes for an absent or null value, so a payload that never sent them is unaffected. See docs/api.md
FeatureComment goes back to $guarded = ['id']. It had swapped to an explicit $fillable, making it the only one of the five Feature* models not using $guarded. It protected nothing — FeatureService::comment() builds its array from explicit typed parameters, so no user-controlled array reaches create() — while silently dropping external_id for host code that mass-assigns its own
laravelcrm:update no longer swallows failures.migrate and the base seeder were wrapped in try/catch, downgraded to warnings, and the command still printed Laravel CRM is now updated. and exited 0 — so a broken upgrade and a clean one produced the same deploy log and the same exit code, and a deploy script's && chain carried on over a half-applied schema. Both are now fatal: the command prints an error and returns a failure exit code, and db_version is stamped only on the success path. A deploy script that relied on this command always succeeding will now stop where it previously carried on. It also gained --force for non-interactive use, calls laravelcrm:upgrade first so one command by hand still does everything, and had its description corrected from Install Laravel CRM package
laravelcrm:update runs the lookup-data seeders operators used to run by hand — laravelcrm:lead-sources, and on teams installs laravelcrm:permissions, laravelcrm:labels, laravelcrm:addresstypes, laravelcrm:contacttypes and laravelcrm:organizationtypes. All are idempotent (updateOrInsert / firstOrCreate / existence-checked inserts), so re-running revokes nothing and duplicates nothing
SystemCheckService no longer depends on a hand-maintained list to notice that an update is due.DB_UPDATE_REQUIRED now fires on any of three signals: a db_update_* flag still at 0 (as before), a missing or stale db_version, or an unrun migration belonging to this package (guarded on repositoryExists(), so a host that has never migrated still reports UPGRADE_REQUIRED instead). The pending-migration check is scoped to the package's own files — those loaded from database/updates, plus published stubs matched back to the .stub set by filename — so an unrun migration belonging to the host application or another package cannot raise a CRM banner telling the operator to run laravelcrm:update over it. The DB_UPDATES list stays as the worklist of data backfills, but is no longer load-bearing for detection — it was frozen at db_update_1201 and nothing complained
Published migration stubs are stamped from a fixed 2024-01-01 epoch rather than the moment of publishing. date('Y_m_d_His', strtotime("+$order sec")) meant a stub published today sorted after a package-loaded migration authored earlier in the same year, so on a fresh install the migrator would try to alter tables that did not exist yet. Hosts that have already published keep their existing filenames — the glob-reuse branch returns the published path unchanged
Line item quantities accept up to 3 decimal places — a product sold by weight or volume (3.5 Kg, 0.25 L) can now be quoted, ordered, invoiced, delivered and purchase-ordered at its real quantity. Previously quantity was an integer column behind a bare <input type="number">, so a fractional quantity could not be entered at all.
quantity widens from integer to decimal(15,3) on crm_quote_products, crm_order_products, crm_deal_products, crm_invoice_lines, crm_purchase_order_lines and crm_delivery_products. The new range strictly contains the old INT range, so every existing row widens losslessly and NULLs stay NULL — see docs/upgrading.md for the write-lock note and the fact that rolling back truncates
New Support\Quantity helper and HasDecimalQuantity model trait: quantities round to 3dp on write and read back as floats on every driver, so a whole quantity still renders as 2 rather than 2.000
The Order → Invoice and Order → Delivery quantity dropdown is now a bounded number input. The dropdown was built by an integer for loop over the remaining quantity, so an order line of 2.5 could only ever be invoiced as 2, leaving 0.5 outstanding forever. The cap on the outstanding quantity is now enforced server-side as well — previously it lived only in the browser, so an over-invoice was reachable by posting the form directly. The submitted quantity is checked against a remainder recomputed from the order line and the invoices or deliveries already raised against it, not against the row's own quantity_max (a public Livewire property, and so whatever the caller sends back). The delivery form, which ran no validation at all, now validates its quantities
Breaking (API response type):quantity in the quote / order / invoice API responses was cast to an integer and is now a JSON number that may come back fractional. Clients decoding it into an int will truncate. The request side is a widening — integer|min:1 becomes numeric|min:0.001|max:999999999 with at most 3 decimal places, so every previously valid payload still passes
Xero invoice and purchase order sync now sends Quantity as a JSON number (Xero's LineItem.Quantity takes 4dp, so 3dp fits). Xero recomputes LineAmount itself and may round a fractional line differently from the amount stored here
Breaking: the users index swapped its query-string filters. The #[Url] properties user_id and label_id are replaced by role_id (array) and crm_access (nullable string), matching the filters the page now offers. A bookmarked or generated ?user_id= / ?label_id= link is ignored rather than erroring. See docs/upgrading.md
Breaking (API): discount and tax gained min:0 on quote / order / invoice writes, alongside the subtotal / total change above. A negative value in either previously passed validation and inflated the computed total past the sum of the line items
The users index and the Templates settings page moved from MaryUI's tabs component to DaisyUI tabs-lift radio tabs, matching the rest of the rebuilt UI. The active tab's radio is checked on first render, so the default tab is highlighted before any interaction rather than after the first click
The General Settings sidebar item is matched exactly now, in both the live sidebar and the legacy v1 settings side-card. MaryUI's activate-by-route falls back to a URL prefix match, so /crm/settings stayed highlighted while sitting on /crm/settings/templates or /crm/settings/feature-statuses
Removed
create_audits_table.php.stub — a leftover from when this package depended on owen-it/laravel-auditing. It has no entry in the publish array, so it could never reach a host, and the dependency itself is long gone
LaravelCrmUpdate::checkQuantityColumns() — a release-specific check that existed only to compensate for migrate failures being swallowed. Now that a failed migration exits non-zero, the general mechanism covers it; the migration itself stays pinned by tests/Feature/QuantityMigrationTest.php
Breaking: the laravel-crm.users.sendinvite route. The only named route dropped since 2.3.0 — invitations run through the crm_user_invitations table and its Livewire surface now. route('laravel-crm.users.sendinvite') throws RouteNotFoundException, so host code referencing it needs updating. The package's own last caller, resources/v1/views/users/partials/card-invite.blade.php, is unreachable dead code (resources/v1 is registered as a view namespace nowhere) and is left as-is
Breaking: the SystemCheck middleware. It was pushed onto the crm middleware group and reported through flash messages; the banner is the crm-system-check Livewire component backed by SystemCheckService now. A host referencing VentureDrake\LaravelCrm\Http\Middleware\SystemCheck in its own stack needs the reference removed — the class is gone
Fixed
laravelcrm:sample-data --fresh did not clear crm_user_invitations. The table was absent from the truncate list, so a re-run doubled the invitations and left the earlier ones pointing at a sample user the same run had just hard-deleted — the delete could not be blocked, since the truncate step disables foreign key constraints around it, so the dangling invited_by simply persisted
Sample deals landed with no pipeline stage, and sample document totals could read a cent short. Two long-standing seeder faults that only surfaced once the seeder had test coverage:
LaravelCrmPipelineTablesSeeder writes the Deal stages with firstOrCreate(['id' => n], ...), and id is guarded on the model, so the rows it means to create at 35/36/37 are created at 10/11/12 instead — the ids Pending, Closed Won and Closed Lost are keyed on, which then match and are skipped. The Deal pipeline came out with four stages rather than seven and roughly two thirds of the sample deals had nowhere to sit. ensureDealPipelineStages() now ensures all six non-Draft stages by name rather than only the three intermediate ones
Line amounts and per-line tax were rounded in dollars, which put fractional-quantity lines up to a cent away from the figure CheckAmount recomputes from quantity x price and flagged the document as broken on the index. Both are now rounded to whole cents, the way the columns store them
TeamObserver::seedCrmDataForTeam() gave every existing team a second copy of six lookup tables. Labels, organization types, address types, contact types, industries and tax rates were copied with an unconditional INSERT, while only the pipelines/stages block below them used updateOrInsert — so the db_update_1201 backfill, which calls the helper for every team, duplicated the lot on any teams install upgrading from 2.3.0, where TeamObserver::created() had already seeded them. The result was two of every label, tax rate, industry and type in every dropdown, with fresh UUIDs and no clean de-dup key afterwards. laravelcrm:v2 was worse: it calls the same helper with no db_update_* marker guard at all, so each run added another set. All six now upsert on team_id + the row's name; external_id and created_at are written on first insert only, so a re-run neither re-keys a row the host is already linking to nor resurrects one the team deleted. The pipelines and stages block was moved onto the same helper for the same reason — it was idempotent on row count but re-minted external_id on every run. Fresh installs were never affected: laravelcrm:install runs the backfill before any team holds data. Pinned by tests/Feature/Observers/TeamObserverSeedTest.php
The two comments asserting the helper was already idempotent — in LaravelCrmUpdate and LaravelCrmV2 — described only the pipelines block and are corrected
Every team now gets its own public portal, and LARAVEL_CRM_PORTAL_TEAM_ID is optional. Under laravel-crm.teams the portal was hard-wired to one team: unset, the feature board and every public feature 404'd outright; set, only that team had a portal at all. A public roadmap is read by a team's customers, who are anonymous and carry no currentTeam, so the team cannot be inferred from the session — new Support\PortalTeam resolves it from a team-addressable board URL (/p/features/team/{id}), the board remembered in the visitor's session, the signed-in user's current team, or the only team that has a board, in that order. A single-team install needs no configuration; portal.team_id, when set, still behaves as the single-tenant lock it always was. The admin features index gained a Public board button carrying the team-scoped link
A public feature is now reachable by its own link whichever team owns it, and opening one moves the visitor onto that board for the rest of the session, so "back to the board", voting and commenting all follow. An install with portal.team_id set keeps 404ing everything outside that team
Portal submissions no longer 403 the people the portal is for. The submit path required the submitter's currentTeam to equal the board's team — but a visitor who registered through /p/register holds no host-app team, so every genuine public submission was rejected. Features are stamped with the board's team instead
PDF documents fall back to a published view the host has customised. Rendering moved to the new template registry with modern as the default, which silently retired any PDF restyled the pre-picker way — by publishing and editing resources/views/vendor/laravel-crm/invoices/pdf.blade.php and its siblings. PdfTemplateRegistry::viewForModel() now prefers that file when it exists and differs from the packaged copy; an explicit choice on the record or in Settings → Templates still wins. The content comparison matters: vendor:publish --tag=views copies the whole directory, so presence alone would have pinned a host that published last week to the old layout forever. The Templates page warns on any tab where an override is in effect, since saving it writes a choice for all five doc types at once
Emailed PDFs ignored the template picker.SendQuote, SendInvoice and SendPurchaseOrder loaded laravel-crm::quotes.pdf and friends directly, so the attachment a customer received did not match the document the sender had just downloaded. All three resolve through PdfTemplateRegistry::viewForModel() now
orderComplete() / invoiceComplete() / deliveryComplete() compared floats with > 0 — a 1.1 order fulfilled as 0.7 + 0.4 leaves 1.11e-16 behind, so the document read as "not fully invoiced" forever. Latent while quantities were integers; user-visible the moment decimals exist. Now compared within half the smallest storable unit
CheckAmount compared line and document totals with an exact == — price is integer cents, so 3.5 × $9.99 computes 3496.5 against a stored 3497 and every fractional line would have shown a red mismatch icon on the show page and a "broken document" badge on the index. Each line is now rounded to the cent the same way the stored one was, and subTotal() / total() sum those rounded lines rather than rounding once at the end — otherwise two lines of 0.5 × $9.99 store 500 + 500 = 1000 but compute 999, and a perfectly consistent document reads as broken. subTotal() / tax() / total() also now return a real bool — they previously returned true or fell off the end returning null
The line item quantity was cast with (int) before pricing — a quantity of 3.5 at $1.99 stored 597 cents instead of 697, and the document header followed the lines, so nothing looked wrong
The deal form's (int) $product['quantity'] ?? 1 fallback was dead code — (int) binds tighter than ??, so the default never applied
The Order line-item loop leaked $quantityRemaining across iterations, so a line that did not draw down inherited the previous line's remainder
laravelcrm:v2 was broken on the v1 → v2+ upgrade path.fix_line1_nullable_on_laravel_crm_addresses_table hardcoded the crm_addresses table name instead of reading db_table_prefix, so a host on any other (or an empty) prefix halted the migrate run — which then cascaded into missing feature_statuses and pipeline_stage_probabilities on the seeders that followed. The command also passed an unsupported --force to flasher:install, which already wipes and rebuilds public/vendor/flasher/ on its own. Alongside those: renames now run before migrations, migration stubs are force-published so previously broken published copies are refreshed, nine dangling stub references were pruned from the service provider, the customer / organization stubs gained idempotency guards so a re-run against an already-renamed database no longer fails, and the corrupted add_customer_to_laravel_crm_deals stub — which was writing url columns across many unrelated tables — is repaired
Option-backed custom fields could not be saved.select, radio and checkbox_multiple values are stored as the FieldOption id, but the sample data seeder wrote the option's value string, so editing any record carrying one failed Rule::in([option ids]) — and for checkbox_multiple the errors landed on fields.N.* keys that no input rendered, so the form silently did nothing on save. Legacy option-value strings are mapped to ids on hydration, the wildcard keys gained messages and attributes, checkbox_multiple renders its errors, display falls back to matching by value, and the seeder writes ids
Masked money values were stored unnormalised. Money inputs are masked in the UI, so a value such as "$1,234.56" reached the model mutators and services as a formatted string; multiplying it by 100 raised A non-numeric value encountered, or a TypeError on an empty value, when saving quotes, orders, invoices, purchase orders and deals. New Support\Money normalises a user-supplied value to a float or to integer cents, and every money mutator and the services' tax and amount-due arithmetic route through it
The line item totals footer zeroed out and stayed there.updatedProducts() reset sub_total, tax and total before returning early for any update that replaced the products array wholesale rather than editing one {index}.{attribute} — and the sum itself cast the masked input values, so "14,000" counted as 14. Totals are now always summed from the lines through Money::toFloat, derived from the lines on mount so a stale stored total cannot mask them, and dispatched when a row is added
Cancelling an inline activity edit did not restore the fields.edit() snapshotted the form values into a private$revert array, which Livewire does not dehydrate — and edit() and cancel() are separate requests, so the snapshot was always empty by the time cancel() ran. On calls, meetings, lunches and tasks that made Cancel a silent no-op, with the edits left on screen as though they had been kept; on notes it was worse, cancel() indexed $this->revert['content'] directly and threw Undefined array key. mount() and cancel() now share a private hydrateFromRecord(), so cancel means "discard the edits and re-read the record" — no state has to survive the round trip, and the fields are right even if someone else edited the record meanwhile
LeadCreate::mount() threw on a null pipeline stage. The lookup is null-safe now, so a lead pipeline with no stages renders the form rather than a 500
The brand logo did not render in generated PDFs. DomPDF will not fetch an http(s) URL unless the host enables dompdf.enable_remote, which is off by default — so the raw storage path the templates wrapped in asset('storage/...') produced a broken-image box on every render. New Support\PdfLogo reads the file off the public disk and inlines it as a base64 data: URI, needing no host configuration; it is applied at all 14 PDF render sites. A missing logo file now resolves to null so the templates fall back to the organisation name as text rather than a broken image. The Settings → Templates preview was unaffected because it already inlined the file, which is why this went unnoticed. The portal's on-screen views and campaign emails keep the asset() URL, which browsers and mail clients fetch fine
PDF template thumbnails are served from the package. The Templates picker linked them through asset() behind a file_exists(public_path(...)) check, so any host whose published assets predated the artwork silently degraded to text-only placeholders. They now resolve through a settings-gated route that prefers the host's published copy and falls back to the copy inside the package
db_update_* flags are read and written install-wide. They describe the schema, which is install-wide, but they went through BelongsToTeamsScope — and a console command has no authenticated user and so stamps no team_id. laravelcrm:install and laravelcrm:update therefore wrote rows a teams host could never see: every team got its own copy seeded at 0, and a freshly installed or freshly updated install reported database updates it had already applied. New SettingService::setInstallWide() drops the team scope and rewrites every row of the same name, so per-team duplicates left by older versions clear too; a flag counts as pending when any of its rows holds 0, so a stale duplicate cannot mask a genuinely outstanding update
The updates page compared versions as raw strings.'2.2.0' >= '2.10.0' is true in PHP — it compares character by character — so the page claimed the install was up to date and stopped offering the update the moment the minor version reached double digits. Both comparisons use version_compare() now and are derived from one computed value so they cannot drift out of being exact inverses. The page also reads through app('laravel-crm.settings') rather than two inline Setting::where(...) lookups, and laravel-crm.updates.index gained a can:view crm updates gate, matching the sidebar link that has always carried one
A feature status could be deleted while features still pointed at it, orphaning them on the board; the delete is refused with a message now. Clearing the "default status" flag is also scoped to the current team, so marking a default on one team no longer unsets every other team's
The invitation email resolved the team name badly on hosts without Jetstream. It goes through the configured host team model with sensible fallbacks now
The Pending Invitations tab is gated on create crm users, matching the Invite button beside it, and the team_user insert on accept is deduplicated so accepting cannot add a second pivot row
The teams dropdown is gated on config('laravel-crm.teams') rather than on the modules Blade directive, so a host running the CRM single-tenant no longer sees a switcher for teams it does not have
product-attributes routes were 403 for every user including Owner: the route parameter was named {productCategory} while the can: guard read productAttribute, so the gate was handed null and resolved no policy
The deal / quote / order products sub-resource groups asked for a permission they were designed not to need. Each route carried a per-route can:view,{param} / can:update,{param} guard underneath the group's can:manageProducts,<Model>. can:update resolved to the same edit crm <entity> check as manageProducts and was pure duplication; can:view resolved to view crm <entity> — and manageProducts exists precisely so that line items key off the parent's edit permission and nothing else. A custom role built under Settings → Roles holding edit-but-not-view could open a deal's form and then 403 on the line items embedded in it. The group gate is now the single rule, which is what the shipped roles and the tests always described
Every method on DealProductController / QuoteProductController / OrderProductController now type-hints its parent, so a nonexistent parent 404s at the binding, and $id lands on the parameter it names — with two route parameters and neither bound, arguments were passed positionally and show($id) was handed the parent's key rather than the product's
Security
Authorization is now enforced on every mutating Livewire action and CRM route — every action now checks the same Spatie permission the UI already advertised via @can. Previously the Blade layer hid buttons a user could not use, but the Livewire components behind them did not re-check on the server, so any user who could reach a CRM page could invoke its actions directly over the Livewire endpoint regardless of role.
can: middleware added to the previously ungated activities/* route group and the deal / quote / order products sub-resource groups
Cross-team user deletion and non-Owner Owner role assignment now blocked; the users listing is scoped to the current team so the visible set matches the actionable set
38 @can / @canany gates added across 20 Blade views, and kanban cards are no longer draggable without the matching edit permission (they previously dragged, then 403'd on drop)
New ActivityPolicy; the orphaned ProductAttributePolicy is now registered (it existed but was never wired up, so every ProductAttribute authorization check silently fell through to deny)
ActionAuthorizationCoverageTest added as a regression guard — a mutating Livewire action without a guard or a documented exemption fails the suite by name
No new permission name is introduced. Stock Owner and Admin receive Permission::all(), and Manager and Employee hold create / view / edit / delete on the core CRM entities, so no seeded role loses access it previously exercised (see the upgrade guide for the exact per-role breakdown)
Upgrading — re-run the permission seeding first. Installs that have upgraded over time without re-seeding may be missing later-release permissions (crm monitors, crm features, crm email-campaigns, crm sms-campaigns, crm chat, crm activities) and will start returning 403. Run php artisan laravelcrm:update (or db:seed --class=...\LaravelCrmTablesSeeder) to create any missing permission rows — the seeder is firstOrCreate + givePermissionTo throughout, so it is idempotent and revokes nothing. On multi-tenant (teams) installs, follow it with php artisan laravelcrm:permissions, which fans the global CRM roles out to each team; that command creates no permissions of its own and exits with an error when teams are disabled. Custom view-only roles built under Settings → Roles will correctly lose actions they could previously perform, and hosts with a trimmed config('laravel-crm.modules') will 403 on the disabled module. See docs/upgrading.md for the full guide.
Ships as a minor release, not a patch — it changes observable behaviour for existing users
Owner-escalation vetting is centralised in Role::assignableBy(). Roles arriving from user creation, user editing, invitation and CSV import are all vetted through one predicate now, so a non-Owner cannot grant Owner by any route. Previously each site made its own decision and they disagreed
Role::assignableBy() layers the Owner check onto assignable(), so the role dropdowns, the AssignableRole validation rule and UserInvite share one predicate and cannot offer a role the caller may not hand out. A null caller (console, queue, unauthenticated) is treated as not an Owner
UserImport resolved roles with a bare Role::where('name', $csvColumn) and Role::find($this->defaultRole). Both inputs are caller-controlled, so anyone holding create crm users could import role=Owner, a host-application role such as super-admin, or a role belonging to another tenant. Both go through assignableBy() now, and an unassignable role is dropped rather than failing the row. Its role dropdown also filtered on where('team_id', currentTeam->id), which rendered empty under teams (the seeded roles carry team_id => null) and 500'd for a user with no current team — the options offered and the values accepted can no longer diverge
UserController::store/update and UserCreate::save resolve and vet the role before the user row is written. A blocked escalation previously aborted after forceCreate(), leaving an orphaned role-less user and burning the email address against the unique index
UserEdit prefilled its role from the unfiltered Spatie roles() relation, so a host-application role or an Owner being edited by a non-Owner was silently offered back
UserIndex::users() team scoping no longer 500s on a host that enables laravel-crm.teams without shipping Jetstream's team_user pivot (Spark Classic names it team_users), and stops hiding a team owner from their own user list — Jetstream keeps owners out of the pivot and merges them back in via Team::allUsers()