Releases: fleetbase/core-api
Release list
v1.6.68
v1.6.68 — Resource transformers apply to every resource
Added
- Agnostic resource transformers. Any extension can decorate the serialized output of any API resource without modifying the resource or its model. Register a transformer against an HTTP resource class, an Eloquent model class, an interface, or
'*'(subclasses match), andFleetbaseResource::resolve()applies it to JSON responses, nested resources, collection items, webhook payloads and broadcast payloads. Transformers chain in ascendingpriorityand can be scoped bycontexts(http,webhook,broadcast) andonly(internal,public). (#285) Fleetbase\Contracts\ResourceTransformer,Fleetbase\Contracts\PreparesResourceTransformation(a once-per-collectionprepare()hook for batch loading, so transformers never add N+1 queries),Fleetbase\Support\ResourceTransformerContext, and theFleetbase\Http\Transformers\Transformerbase class.- Closure transformers via
ResourceTransformerRegistry::register(fn (...) => ..., ['target' => ...]). CoreServiceProvider::$transformers,registerTransformers()andregisterTransformersFrom(__DIR__ . '/../Http/Transformers')for declarative and directory-based registration from extensions, mirroring expansions.
Changed
FleetbaseResourceCollectionresolves items (instead of callingtoArray()), sharing oneprepare()pass per collection. A hand-built collection with a manually setpreserveKeysnow filters item arrays with the item's flag.ResourceLifecycleEventpayloads, chat participant broadcasts,Utils::serializeJsonResource()and the cached internal user payload serialize throughresolve(), so transformers reach them and conditionalMissingValues are no longer emitted as{}.Find::httpResourceForModel()caches internal and public resolutions separately, consulting the request only when a model has a dedicatedInternalresource.
Removed
- Legacy duck-typed transformers (
$targetproperty + staticoutput($model, $data)),ResourceTransformerRegistry::transform(Model, array),resolveByTarget(),fixClassName()and the static$transformersarray. TheUserresource no longer calls the registry directly.
Dependencies
fleetbase/laravel-mysql-spatial^1.0.3. The spatialMysqlConnectionno longer connects to MySQL when the connection object is built, so resolvingDB::connection()during boot (for exampleartisan package:discoverduringcomposer install) no longer requires a reachable database.
Upgrade Steps
- Extensions that registered a legacy transformer must implement
Fleetbase\Contracts\ResourceTransformer(or extendFleetbase\Http\Transformers\Transformer) and register it through$transformersorregisterTransformersFrom(). See the README section "Resource transformers". The only known legacy consumer, aws-marketplace, is deprecated and is not updated.
What's Changed
- feat(resources): agnostic resource transformer registry by @roncodes in #285
- release: v1.6.68 by @roncodes in #286
Full Changelog: v1.6.67...v1.6.68
v1.6.67
v1.6.67 — API keys are generated randomly
Security
- API keys created in the same second were identical, across organizations. A key was derived from its creation time and row id, but the id is never loaded after insert (the primary key is the uuid), so every key created in the same second got the same value. API authentication resolves a key to the first matching credential, so a key issued to one organization could authenticate as another's. Keys are now 32 random characters from the CSPRNG, for new keys and for rolled keys. (#283)
Upgrade Steps
- Check for existing duplicate keys and roll every credential that shares one, in both the live and sandbox databases:
SELECT `key`, COUNT(*) AS credentials, COUNT(DISTINCT company_uuid) AS orgs FROM api_credentials WHERE deleted_at IS NULL GROUP BY `key` HAVING COUNT(*) > 1;
What's Changed
- fix(security): generate API keys from the CSPRNG instead of the creation time by @roncodes in #283
- release/v1.6.67 by @roncodes in #284
Full Changelog: v1.6.66...v1.6.67
v1.6.66
v1.6.66 — Per-consumer API rate limiting, admin rate-limit controls and API consumer metrics
Fixes
- One API consumer can no longer rate-limit the whole platform.
ThrottleRequestsran before API authentication, so Laravel keyed every bucket on the client IP. Behind a load balancer that is the balancer's IP, so every tenant, API key and console visitor shared a single 120/min bucket, and one busy integration returned 429 to everyone. The limiter now keys on the presented credential (API key, Sanctum token or basic auth), falling back to the user and then the IP only when none is sent. The first path segment keeps/v1and/intin separate buckets. (#280) - 429 responses keep
Retry-AfterandX-RateLimit-*. The exception handler used to drop them, so throttled clients could not back off correctly. (#280)
Improvements
- System admins can manage API rate limits at runtime. The limits (
THROTTLE_*environment defaults) can be overridden from the console and stored as thesystem.rate-limitssetting: enable/disable, requests per window, and window length. Per-organization overrides give an organization a custom limit or none. New admin-only endpointsGET/POST/DELETE int/v1/rate-limits/settings. (#281) - API consumer metrics. The throttle middleware counts every request and every 429 per consumer in Redis: minute buckets are kept for 2 hours, hour buckets for 8 days, at one pipelined round trip per request.
GET int/v1/rate-limits/consumers?window=&sort=&limit=lists the busiest or most throttled consumers with their organization, masked key, scope, IP, avg and peak per minute, and share.POST int/v1/rate-limits/consumers/{signature}/resetclears one consumer's window. Tracking can be disabled withTHROTTLE_TRACK_CONSUMERS=false;THROTTLE_METRICS_REDIS_CONNECTIONpicks the Redis connection (defaultcache). (#281)
What's Changed
- fix(throttle): key the API rate limiter on the consumer, not the proxy IP by @roncodes in #280
- feat(throttle): admin-managed rate limits, org overrides and API consumer metrics by @roncodes in #281
- release/v1.6.66 by @roncodes in #282
Full Changelog: v1.6.65...v1.6.66
v1.6.65
v1.6.65 — Authenticator-app 2FA, sign-in hardening and IAM permission fixes
Improvements
- Organization administration supports company and owner identity search, country/timezone/owner-IP and registration/update-date filters, and sorting by current user count. Admins can open organizations outside their own memberships, inspect organization-scoped usage totals, and see members' 2FA methods and linked OAuth providers without exposing authentication secrets.
- Sign in with an authenticator app (TOTP, RFC 6238), next to email and SMS 2FA (#163). It works with Authy, Google Authenticator, Microsoft Authenticator and 1Password.
- New
users/two-fa/authenticatorendpoints to set up, confirm, disable and inspect the app. Setup, disable and regenerating recovery codes need the current password. - Confirming the app returns 8 single-use recovery codes.
two-fa/verifyaccepts an app code (±1 step for clock drift, each code once) or a recovery code. two-fa/resendfalls back to an emailed (or SMS) code and returns the newmethod.- The secret is encrypted with the app key, and recovery codes are stored as keyed hashes.
- New
Fleetbase\Support\Barcodehelper with a compactqrCodeSvg().
- New
- Record the app, APNs environment and last-seen time on user devices. New nullable
user_devicescolumns:app_identifier(indexed),environmentandlast_seen_at. - New organization setting to let users change their own password (default on), at
GET/POST companies/auth-settings.GET users/password-policyreturns{can_change_password}.
Fixes
- Settings → Notifications applies when notifications are sent from the queue or a console command (#262).
NotificationRegistrytakes the company from the notification's subject instead of the session.notifyUsingDefinitionName()reads the company-scoped settings instead of a global key nothing writes. NewSetting::lookupForCompany(). - Invited users can set their first password, and non-admins can change their own (#263). The password endpoints no longer fall through to
iam create user. - FCM order notifications are sent with Android
priority: high, so drivers' phones in Doze get them immediately (#268).
Security
- A 2FA session starts only after the password is checked.
GET two-fa/checkused to start one from the identity alone, so an email plus the emailed code was enough to sign in. It now always returns{twoFaSession: null, isTwoFaEnabled: false}and no longer reveals whether an account uses 2FA. - 2FA sessions expire after 10 minutes; they used to live about 56 years. The 5th wrong code deletes the session, and resending doesn't reset the count. Codes are compared in constant time and generated with
random_int. users/change-passwordrequirescurrent_passwordin the same request, andiam change-passwordis enforced.users/set-passwordworks only once, within 24 hours of accepting an invite.validate-passwordandchange-passwordare limited to 10 requests per minute.- Close authorization gaps where
AuthorizationGuardresolved to permission names that don't exist:- Updating the organization and its 2FA policy is limited to the owner, the Administrator role and system admins. Non-admin updates ignore
owner_uuid, Stripe ids,plan,status,trial_ends_atandtype. POST two-fa/config(system 2FA policy) and admin platform metrics are limited to system admins.- IAM and developer metrics need
iam list user/developers list api-key. - Reports use the
iamservice:iam execute reportfor direct queries,iam export reportfor exports. - API credentials, webhooks, API events and request logs check the
api-key,webhook,eventandlogpermissions.
- Updating the organization and its 2FA policy is limited to the owner, the Administrator role and system admins. Non-admin updates ignore
- Password, auth-setting and authenticator changes are written to the
authactivity log.
Behaviour changes
- Consoles need the companion fleetbase/fleetbase changes: the 2FA sign-in flow (
fix/2fa-login-hardening),current_passwordon change-password (fleetbase/fleetbase#685) and the authenticator-app UI (fleetbase/fleetbase#686). With an older console, users with 2FA can't sign in and self-service password changes fail. - Users need
iam … reportpermissions to use reports, anddevelopers …permissions for API keys, webhooks, events and logs. Fleet-Ops' report screens check the same names in fleetbase/fleetops#345. - A user who accepted an invite before this release but never set a password should use Forgot password.
Run the migrations for the new user_devices columns. Run composer update to install pragmarx/google2fa.
Changes: #272, #273, #274, #275, #276, #277, #278.
What's Changed
- fix(2fa): start two-factor sessions only after the password is checked by @roncodes in #272
- feat(2fa): sign in with an authenticator app, with recovery codes by @roncodes in #277
- fix(notifications): resolve notification settings from the notified company by @roncodes in #274
- fix(iam): enforce the change-password permission and let invited users set a password by @roncodes in #275
- fix(push): send FCM order notifications with android high priority by @roncodes in #273
- feat: record app and APNs environment on user devices by @roncodes in #276
- fix(iam): close authorization gaps in core controllers by @roncodes in #278
- Release v1.6.65 by @roncodes in #279
Full Changelog: v1.6.64...v1.6.65
v1.6.64
v1.6.64 — Order reporting and test SMS with the entered credentials
Improvements for reporting
- Declare row-level expression columns with
Column::expression($name, $sql, $type). Bare names resolve against the table or relationship that declares it, soJSON_EXTRACT(meta, '$.quantity')onpayload.entitiesreads the joined entity'smeta. An expression column can be selected, filtered, sorted, grouped by and aggregated. - Summary columns (
Column::count/sum/avg) are flaggedaggregateand resolve to their computation. Without grouping they return a single summary row; with grouping they sit beside the group keys. Table::softDeletes()andRelationship::softDeletes()leave out soft-deleted rows. On joins the filter goes in theONclause, so LEFT joins keep the parent row.- Computed columns can be group keys, conditions and sort columns, and a grouped report can be sorted by an aggregate's alias. A new
count_distinctaggregate is available. - Custom expressions accept the JSON functions,
DATE(),CAST(… AS DECIMAL(15,2))and the other cast types,DISTINCT,IN,GROUP_CONCAT(… ORDER BY … SEPARATOR …),->/->>andINTERVAL n UNIT. public_idandinternal_idare no longer hidden as foreign keys, so ID columns appear in the column picker.- Relationship columns are labelled with the whole relationship name ("Order Config Namespace", not "Order Namespace"), and aggregate labels use the column label ("Sum (Quantity)").
_keyand_import_idare never listed or selectable, whatever a schema declares.
Fixes
- Test SMS Provider and Test Twilio in Admin › System Config › Services use the credentials entered in the form (fleetbase/fleetbase#680). Under Octane the Twilio client was built once per worker, so a test failed with "Credentials are required to create a Client" or reported success for the saved account. The endpoints now rebuild the client from the request's config and release it after the send.
Security
- Every computed column is validated up front, including in grouped reports. Names must be safe identifiers, and
SELECTis forbidden. - Schema-declared columns always take their SQL from the registry, never from the request. Group keys, aggregate columns, sort columns and condition fields must be allowed or computed columns.
- Sort direction is normalised to
asc/desc, and grouped reports validateaggregateBy.computation.
Behaviour changes
- In a grouped report, a selected column that is neither a group key nor aggregated is now an error instead of being dropped.
- Invalid report shapes fail with a clear message instead of an SQL error.
A database migration is not required. No configuration change is needed. The FleetOps order report schema ships in fleetbase/fleetops v0.6.70, and the report builder changes in fleetbase/ember-ui v0.4.4.
What's Changed
- Send test SMS with the credentials entered in the console by @roncodes in #270
- Report on order items, JSON totals and computed group keys by @roncodes in #269
- Release v1.6.64 by @roncodes in #271
Full Changelog: v1.6.63...v1.6.64
v1.6.63
v1.6.63 — Driver, customer and contact accounts stay out of the console
Improvements
- Treat
driver,customerandcontactusers as managed accounts: the FleetOps profile owns them, not IAM.UsergainsMANAGED_TYPES,isManagedAccount(),isStaffAccount(),canAccessConsole(),canHoldConsoleSession()and amanaged()scope. - Promote instead of duplicating. When IAM creates or invites a team member whose email or phone belongs to a managed account in the organization, that account becomes a
user. It gets the chosen role, permissions and policies and a join invite, and keeps its driver and customer profiles. The response carriespromoted_from. Accepting any IAM invite also promotes a managed account and asks it to set a console password.
Improvements for IAM
- Let IAM admins ask a user to verify their email or phone.
POST users/{id}/send-verificationsends a one-click link by email or SMS; it lasts 48 hours. The publicauth/confirm-contact-verificationconfirms it without signing in, and refuses the link if the address changed since.users/verify/{id}takes achannel(email by default, or phone).UserFilteraddsemail_verified,phone_verified,countryandtimezonefor the new IAM columns.
Fixes
- Keep managed accounts out of the console:
auth/loginrefuses drivers, contacts and customers. Customers keep thecustomer_login_not_allowedcode; drivers and contacts getconsole_access_not_allowed.- Session restore, bootstrap, 2FA verification, verify-email tokens and impersonation refuse drivers and contacts.
- Customers are still allowed on those endpoints because the customer portal runs inside the console and restores its session through them.
- Free a deleted user's email and phone so a new account can use them. On soft delete they move to
meta.deleted_identity; restoring the user puts them back only if no other account has taken them.
Security
-
Never grant the Administrator role by default. Before this fix:
- Creating or inviting a user without a role gave them the Administrator role, and so full organization access. Reported for IAM › Customers › Add customer with a blank Role.
- Accepting an invite with no role also granted it, and
joinOrganizationignored the invite's role altogether.
Now:
- A role is required when creating or inviting a user; without one the request returns 422.
Company::addUser,Company::assignUserandUser::assignCompanyassign no role unless one is given.- An invite without a role joins with no role.
-
Only admins or holders of the Administrator role may grant the Administrator role (403 otherwise), on create, invite and role update.
Reliability
- Cover the console guards for each account type, identity release and restore, and promotion through create, invite and invite acceptance.
A database migration is not required. No configuration change is needed. The FleetOps side ships in fleetbase/fleetops#338.
What's Changed
- Remove the abandoned CompanyScope global scope by @roncodes in #260
- Keep driver, customer and contact accounts out of the console by @roncodes in #264
- Never grant the Administrator role by default by @roncodes in #266
- Let IAM admins request and record email/phone verification by @roncodes in #267
- feat(oauth): OAuth/OIDC sign-in and signup by @roncodes in #261
- Release v1.6.63 by @roncodes in #265
Full Changelog: v1.6.62...v1.6.63
v1.6.62
v1.6.62 — Custom fields get a public id
Improvements
- Give every custom field a public id, so an API that hands one out names it the way the rest of the platform names a resource rather than exposing an internal uuid.
CustomFieldtakesHasPublicIdwith thecustom_fieldprefix, andpublic_idbecomes fillable. - Mint an id on the one path that would otherwise miss it:
HasCustomFields::setCustomField()saves a field it creates on the fly withsaveQuietly(), which skips the hook that assigns the id.
Fixes
- Let an observer's refusal reach the caller on the update and bulk-delete paths. An observer that refused a write by throwing
FleetbaseRequestValidationExceptionhad its explanation discarded:HasApiModelBehavior::updateRecordFromRequest()rewrapped every exception from the save as a plain\Exception, andHasApiControllerBehavior::bulkDelete()caught\Exceptionahead of its dedicated handler, so callers sawInvalid requestor a generic update error instead of the message the observer wrote. The exception now passes through untouched on both paths and is rendered withgetErrors(), as it already was on create and single delete. Every other exception is wrapped exactly as before. Reported in #256.
Reliability
- Backfill existing rows in the migration, and add the column as nullable and indexed rather than unique-and-required, so it is safe on an already-populated
custom_fieldstable. - Cover id generation for
CustomField, and add the column to the in-memory schemas whose saves now probe it for uniqueness.
This is platform-wide: every custom field gains a public id, not only those used by inspections. Nothing reads the new column yet — withCustomFields()'s public projection emits field names and is unchanged — so the change is additive for existing consumers.
A database migration is required. No configuration change is needed.
What's Changed
- Give custom fields a public id by @roncodes in #254
- Let observer refusals reach the caller on update and bulk delete by @roncodes in #259
- Give alerts a real snooze, an owner and a planned time by @roncodes in #258
- Release v1.6.62 by @roncodes in #255
Full Changelog: v1.6.61...v1.6.62
v1.6.61
v1.6.61 — Faster IAM user authorization loading
Improvements
- Reduce repeated database queries when listing IAM users by loading roles, policies, and permissions in batches and reading each user's primary role once.
- Match authorization to each user's company membership, including users belonging to multiple companies and system administrators viewing users across companies. User response fields remain unchanged.
Reliability
- Add database-backed coverage for company isolation, missing and deleted memberships, recovery after a membership was initially absent, and matching responses between lazy and eager loading.
- Enable PHP CI and Postman checks for
release/v*branches and support release tagging from bothrelease/v*anddev-v*branches.
No database migration or configuration change is required.
Changes: #251, #250, and release-branch CI updates in #252.
What's Changed
- ci(release): adopt the release/v* branch convention by @roncodes in #250
- perf(user): let authorization accessors honour eager-loaded relations (12 → 0 queries/row) by @dounisaur in #251
- Release v1.6.61 by @roncodes in #252
- Fix v1.6.61 release version and notes by @roncodes in #253
New Contributors
- @dounisaur made their first contribution in #251
Full Changelog: v1.6.60...v1.6.61
v1.6.60
v1.6.60 ~ "Revoked keys stay revoked, transactions stop colliding, webhooks stop signing with the wrong secret"
Highlights
Three independent defects, each of which let the platform keep doing something an operator had already told it to stop.
Deleting an API credential did not revoke it. The console hid the row and the key kept authenticating — indefinitely, on every endpoint. Combined with an "expire immediately" option that also did not take effect, a stock Fleetbase console had no working way to revoke an API credential. Anyone who has ever deleted a key in the console should read the upgrade steps.
Persistent PDO handles shared one MySQL transaction. Writes landed and the API still answered 422 There is no active transaction, so anyone who retried after the error applied the write twice.
A queue worker signed every lifecycle webhook with the first event's secret. Every outbound webhook after the first carried the wrong HMAC key, along with the wrong credential, environment and company.
Security Fixes
-
Soft-deleted API credentials still authenticated.
AuthenticateOnceWithBasicAuthlooks the credential up withwithoutGlobalScopes(), which stripsSoftDeletingScopealong withExpiryScope. Expiry was re-applied in PHP; soft-deletion never was. A credential the console reports as Deleted kept authenticating indefinitely — and Delete is the only revocation most operators ever perform. Now rejected with a 401, before theOPTIONSshortcut so a revoked key cannot seed api key session context on a preflight either. -
Authentication was fail-open when the credential's creator was gone. An API credential carries no identity of its own; it acts as the user that created it. When that user no longer resolved — deleted, or soft-deleted on off-boarding — the
is_adminguard was skipped butAuth::setSession()still returnedtrue. Authorization degraded safely, since a null user fails every group and admin check, but authentication did not: the key kept working on every read endpoint and every ungated write. Off-boarding a person did not revoke the keys they had created.setSession()now returnsfalsein that case and the middleware answers a clean 401.
Bug Fixes
-
"Expire immediately" did not expire the credential.
ApiCredential::setExpiresAtAttribute()maps the console'simmediatelyoption toCarbon::now(), butExpirable::hasExpired()used a strict<, sonow() < now()was false.ExpiryScopealready disagreed with it — it keeps a row only whileexpires_at > now(), i.e. it treats an exactly-now expiry as expired.hasExpired()is now inclusive, so the trait and the scope agree. -
Persistent PDO handles shared one MySQL transaction.
Connection::commit()decides whether to issue a COMMIT from its own counter; PDO decides whether one is legal from the server'sSERVER_STATUS_IN_TRANSflag, and nothing reconciled the two. WithPDO::ATTR_PERSISTENT => true, PHP hands the same MySQL session to a second handle built from the same DSN while the first is still using it, so one handle could commit — and thereby invalidate — the other's transaction. Observed on onboarding account creation, ledger invoice creation and inventory stock adjustments, none of which share a code path. Persistence is nowenv('DB_PERSISTENT', false)on themysqlandsandboxconnections. -
A queue worker signed lifecycle webhooks with a stale secret.
SendResourceLifecycleWebhook::setSessionFromEvent()only wrote a session key when it was absent, andhandle()preferred the session value over the event's. Aqueue:workprocess is long-running and its session store is a container singleton, so once the worker handled one lifecycle event, every later event reused that first event'sapi_secret— plus itsapi_credential,api_key,api_environment,is_sandbox,companyanduser. The context serialized onto the event is now authoritative for the job that carries it, and a restorer running in afinallyhands the session back as it was found. Reported in #244.
Testing
- New coverage for revoked credentials on both normal and
OPTIONSrequests, for a credential whose creator has been soft-deleted, forAuth::setSession()returningfalse, and for the exactly-now expiry boundary. - Four tests for the webhook secret bleed, each verified to fail against the unpatched listener, covering per-event signing, sandbox/live isolation, session restoration, and event-over-session credential attribution.
- The middleware fixture previously seeded the sandbox user only on the sandbox connection.
Useris pinned to themysqlconnection andsandbox:syncmirrorsmysql → sandbox, so the fixture now matches the real system.
Upgrade Steps
Audit your API credentials. Any credential deleted from the console before this release was never actually revoked and has been live the whole time. After upgrading, those keys stop working — which is the point, but it means an integration quietly running on a key someone believed they had deleted will break at upgrade rather than at deletion. List your credentials including soft-deleted rows before deploying if you want to know what will change.
Keys whose creator has been off-boarded will start returning 401. That is the intent of the fix, but it is a behaviour change for anyone whose integrations run on a departed employee's credential. Reassign those to a dedicated service user before upgrading.
Deployments not running Octane may see per-request connect cost. PDO::ATTR_PERSISTENT now defaults to off. Under Octane connection counts are unchanged (measured at 17 on a 24-thread FrankenPHP container). Short-lived PHP-FPM workers that relied on the pool will pay roughly 1–3 ms per request; DB_PERSISTENT=true restores the old behaviour, at the cost of re-arming the transaction bug for any request that opens a transaction. Note that persistent connections also silently defeated Octane's DisconnectFromDatabases listener, which now works as intended.
No migration and no configuration change is required.
Still Open
API credentials still carry no scope of their own — Auth::setSession() derives is_admin from whoever created the key, so every key an admin creates is a full-admin key regardless of what it is named. Least privilege is currently only reachable indirectly, by scoping the creator into a dedicated service user. Per-key roles, or an explicit key assignee decoupled from the creator, is a feature rather than a fix and is tracked separately.
Need help?
What's Changed
- fix(database): stop persistent PDO handles sharing one MySQL transaction by @roncodes in #243
- fix(webhooks): a queue worker signed lifecycle webhooks with a stale secret by @roncodes in #245
- fix(auth): api credentials survived revocation and creator removal by @roncodes in #246
- Bugfix: Utils::apiUrl renders query params as path segments by @roncodes in #248
- fix: add the report execution statistics columns by @roncodes in #249
- v1.6.60 by @roncodes in #247
Full Changelog: v1.6.59...v1.6.60
v1.6.59
v1.6.59 ~ "Verification and credentials email can render again"
Highlights
Both mail templates failed to compile, so every verification and credentials email threw instead of sending. Any flow that delivers a code — customer signup, SMS login with email fallback, password reset, account closure, driver login — returned a 400 carrying a Blade parse error.
Shipped in v1.6.56 and present in v1.6.57 and v1.6.58. Upgrade if you are on any of those.
Bug Fixes
-
verification.blade.phpanduser-credentials.blade.phpdid not parse. The greeting readGood Morning@if($user->name), ...@endif, and Blade only treats@as a directive when the preceding character is not a word character — the rule that keepsfoo@bar.comfrom compiling. So the@ifwas left as literal text while its@endifcompiled anyway, leaving an unmatchedendifthat broke the enclosingif/elseif/else:syntax error, unexpected token "else", expecting end of file (View: .../core-api/views/mail/verification.blade.php)The greeting is now built in one expression, so no directive sits against a word.
Testing
- Added a test that compiles every Blade view in the package and runs
php -lover the result. Nothing caught the original break because no test ever compiled a view — the templates were only exercised through mocked mailers, which never render them.
Upgrade Steps
No migration and no configuration change. If verification emails were failing, they will work again once this is deployed; no codes need reissuing.
Need help?
What's Changed
Full Changelog: v1.6.58...v1.6.59