Skip to content

Changelog ​

All notable changes to sp-laravel-api will be documented in this file.

[Unreleased] ​

[0.5.04] - 2026-10-03 ​

Security ​

  • Relationship filters ignored the request's tenant: a relationship filter (?pets.name=eq.x, a grouped or=(pets.name.…), a searchable relationship column) took its tenant from a tenant_id query parameter instead of the request's tenant, so a caller in one tenant could test another tenant's rows (over HTTP, MCP and the AI SDK tools). It is now bound to the request's tenant — through a tenant-scoped belongsToMany pivot too.
  • beforeRead hooks could add tenant-scoped rows without a tenant: over HTTP the tenant check ran before the beforeRead hooks, so an include or a relationship filter a hook added (or a select a middleware set on the request) was not checked. The request is now checked again as the hooks left it, and 422s like a client-sent include.
  • Cached relationship reads were shared across tenants: with record caching on for a table that is not tenant-scoped, a cached response embedding or filtering a tenant-scoped relationship was served to every tenant. Such requests — including a select a middleware or hook sets on the request — are now cached per tenant.
  • Tenant-scoped includes over HTTP: with tenancy on and no tenant resolved, a read of a table that is not tenant-scoped embedded (or filtered on) the rows of a tenant-scoped relationship for every tenant — directly, under an alias (pets:x(*)), nested at any depth (houses(*,animals(*))) or through a tenant-scoped pivot. It now answers 422 (header X-Tenant-ID cannot be empty: it is required to include …), as the MCP and AI SDK tools already refused. Send the tenant header (or resolve resolved_tenant_id) on such requests.
  • viewOwn read the default guard: permission checks read sp-laravel-api.auth.guard, but own-records scoping read the default guard, so a user on a route without auth middleware — or in the queued bulk job (?async=true), which restores the user on the configured guard — passed the permission check without being restricted to their own rows, and could update another user's row. Scoping now reads the configured guard, falling back to the default one.
  • Nested writes bypassed the child table's authorization: a nested create, update or delete (for example PUT /invoices/1 with items: [...]) checked only the parent's permission and the relationship's allow* flags. A user allowed to update an invoice could create, edit and delete its items with no invoice_item permission, even on a table configured canCreate: false / canDelete: false. Every child operation is now authorised as a direct request on the child table, on the HTTP CRUD and bulk endpoints, the async bulk job and the Data MCP: a missing permission is 403 (MCP -32002), a disabled can* flag 422, and nothing is written. The Data MCP now runs create and update in a transaction, so a refused child also rolls back the parent. Trusted app code is unchanged: direct RecordService calls, table and global triggers, post-write hooks and record event listeners write nested children under their own authority, while data a before-trigger merges into the request is still checked. The async bulk job now starts from no user, so a job whose own user cannot be restored never runs as the previous job's user. See tests/Feature/NestedChildWriteAuthorizationTest.php.
  • MCP data tools ignored a table's canRead / canCreate / canUpdate / canDelete flags: those flags switch the HTTP routes off (404), but list_ / read_ / create_ / update_ / delete_{table} were offered and executed regardless, so a canDelete: false table could still be deleted from, and a canRead: false one read, over MCP by anyone holding the permission. A tool for an action the table does not allow is no longer listed and is refused with -32601 Tool not found if called. Tables that keep the defaults are unaffected; the built-in audit, permission and attachment tables lose the write tools their configuration never allowed (for example delete_sp_audit_logs).
  • MCP and AI tool calls no longer reveal database error text or let a tool argument pick the tenant of included rows: a database error reached the client as the raw exception message — SQL with its bound values, which can include a hidden or server-filled column — and now reads "The database rejected the operation." (the original is reported to your exception handler; HTTP already answers a generic message). With tenancy on and no tenant resolved, a call that includes rows of a tenant-scoped relation (select=*,rel(*), with, rel.column filters) returned every tenant's rows, and a tenant_id in queryParams was taken as the tenant; such a call is now refused with -32001 Tenant context is required…. Calls with a resolved tenant are unaffected.

Added ​

  • Opt-in laravel MCP driver: set record.mcp.driver (SP_MCP_DRIVER, default legacy) to laravel and the Data and Schema MCP run on the official laravel/mcp package — Streamable HTTP at POST /{api}/mcp, php artisan mcp:inspector sp-laravel-api, stdio through the existing sp-laravel-api:mcp --tenant= command, protocol negotiation, ping, and tools the caller may not use left out of tools/list (a hidden tool called anyway is still -32002 Forbidden). Both drivers share one tool catalog and one executor (Sopheak\Core\Mcp\ToolCatalog, ToolExecutor), so tool names, schemas, results, tenant rules and the -32001 / -32002 / -32601 error codes are identical. laravel/mcp is a suggest; selecting the driver without it fails at boot with a clear message. The URLs POST /{api}/mcp/message and POST /{api}/mcp/schema are unchanged on both drivers.
  • title and annotations on every MCP tool (both drivers): readOnlyHint, destructiveHint, idempotentHint and openWorldHint, so clients can show a tool's effect before calling it.
  • Opt-in OAuth 2.1 discovery for MCP connectors: record.mcp.oauth (SP_MCP_OAUTH, laravel driver, needs laravel/passport) registers the OAuth metadata and dynamic-client-registration routes claude.ai and ChatGPT custom connectors need. Boot fails with a clear message when Passport is missing; with the option off nothing is registered.
  • Agent guidance in the schema tools: sp_api_get_api_guidance now carries the shared reference an agent needs once — headers (Bearer token and the configured tenant header), querySyntax (relationship filters, not., (any)/(all), search, with_trashed/only_trashed, select vs with), the operators catalogue for the current database driver, pagination, an errors table checked against real responses (error_code 10002, 10008, 10009, 10004 …, MCP -32001/-32002/-32601), rateLimits, nestedWrites, docs, realtime, per-module modules recipes (audit, permissions, attachments) and recommendations with your real limits. sp_api_get_endpoint adds per-action headers, rateLimit, search and paging limits, bulk maxItems and async, fields[].required, relationship aliases and required in create payloads, pivot details for many-to-many includes, the attachment view resizing parameters and a multipart note for upload RPCs. Everything is read from config, routes and the registry at request time.
  • FilterOperatorCatalog: one map of the filter operators, the databases each needs and the column types each suits. The filter engine and the MCP tools both read it, so the operators advertised for a field are exactly the ones the current driver accepts (the fts family and array/range operators on PostgreSQL only, regex/match on MySQL, MariaDB and PostgreSQL).
  • actions argument for sp_api_get_endpoint (for example ["list", "create"]) returns just those actions; an unknown name is an error that lists the available ones.
  • Bare ids attach in nested many-to-many arrays: PUT /notes/1 {"tags": [1, 2]} attaches tags 1 and 2 like [{"id": 1}, {"id": 2}] (belongsToMany, morphToMany, hasManyThrough). Before, every non-object item was dropped silently and the request returned 200 with nothing written.
  • AI SDK record tools: RecordTools::for('invoices'), RecordTools::readOnly([...]) and RecordTools::schema() return Laravel AI SDK tools (composer require laravel/ai, PHP 8.3+) that list, read, create, update and delete your tables in-process, through the same ToolExecutor as the MCP servers — so an agent inherits tenant isolation, Gate permissions, viewOwn scoping, hidden-column stripping and nested-write authorization. ->only() / ->except() narrow a set; writes wait for a person's approval by default (->withoutApproval() / ->requireApproval('reason'); conversational agents only, as laravel/ai requires); queued and background agents carry ->actingAs($user)->forTenant($id) (a ToolContext that installs the user and tenant for one call, always restores them, and serialises the user's key rather than the user). Free-form payload / queryParams travel as JSON-encoded text (providers cannot express an open object and would send "no keys allowed"); refusals, wrong-shaped arguments and failed record operations come back to the model as an {"error": …} string; a schema that cannot be converted, or that would silently lose a property, throws instead of becoming a parameterless tool; unexpected failures are reported and rethrown with a message that carries no internals. record.mcp.read_only does not apply to these tools. See docs/guide/modules/module-ai-sdk.md.

Changed ​

  • Schema tool content corrected: bigInteger and the other Laravel integer types are integer (was string), dates and uuids carry a format; create and update payload schemas list relationship aliases and no longer offer id, timestamps, deleted_at, the tenant column or userstamps as writable, and create payloads list required; belongsToMany includes report the related table as table (the pivot is pivotTable); payloadHint shows shapes that really write instead of [1, {"id": 2}, …] and no longer says "sync"; the filters query parameter is described as {column}={operator}.{value}; like.%acme% examples became like.acme; upload RPCs say multipart/form-data and point at request.payload instead of a payloadSchema key that does not exist; response record schemas no longer claim additionalProperties: false.
  • Smaller MCP responses: content[0].text is compact JSON (was pretty-printed; structuredContent is unchanged), and in an sp_api_get_endpoint result a schema repeated across actions, and the operator list of same-typed fields, is written once and referenced as {"$ref": "#/json/pointer"}. For a 10-column table with three relationships the full result fell from about 86 KB to about 27 KB.
  • A non-object item in a hasMany / morphMany nested array is a 422 naming the relationship (it was dropped silently), and an empty value (0, "", null, false) in any nested relationship array is a 422; nothing is written.
  • McpServerService is a thin JSON-RPC adapter over ToolCatalog / ToolExecutor; its public methods and wire output are unchanged. The Schema MCP token check moved to the VerifySchemaMcpToken middleware (constant-time comparison, same rules and 401 body).

Fixed ​

  • beforeRead hooks could not change filters over HTTP: the filter parser reads the raw query string, so $request->query->set() / remove() (and merge() on a plain GET) in a beforeRead hook had no effect, and a select merged into a JSON request body answered 500. Hook changes now reach the filters (dotted relationship keys kept); a select in the body is ignored.
  • Grouped filters dropped relationship columns: or=(customer.display_name.ilike.x,…) silently lost its relationship conditions although the filter guide documents them. They now match, one level deep, bound to the request's tenant.
  • MCP / AI SDK tool hooks: hooks now see dotted query keys as sent and the resolved tenant in the tenant header; a payload that cannot be JSON-encoded is refused instead of written empty; a delete re-checks tenant-scoped includes its beforeDelete hook adds; queryParams sent as a query string keep dotted relationship keys. The HTTP query-string sync applies only a hook's own changes, also when the hook returns a new Request. The HTTP include check uses the controller's table, not the {table} route parameter.
  • MCP and AI SDK tools run record hooks and validators: the data tools called RecordService::execute* directly, so before* / after* hooks (and the webhooks delivered through them), beforeRead filters, table and default validators and RecordMutated broadcasts did not run for agents — although the MCP guide said they did. They now run in the HTTP controller's order (record.mcp.run_record_hooks, default true). A hook that throws answers A record hook failed; the details are in the application log. and the exception is reported — its message (class names, webhook URLs) never reaches the client or the model. Your own RecordService::execute* calls are unchanged.
  • Queued audit entries for rolled-back writes: AuditLogJob is now queued only after the surrounding transaction commits (ShouldQueueAfterCommit), so a write rolled back by a failing after-hook, a nested-write refusal or a later database error no longer leaves an audit entry. A custom audit.audit_log_job class should implement ShouldQueueAfterCommit too.
  • The built-in permission module bypassed Laravel's Gate: with permissions.enabled, the API, MCP and viewOwn checks asked the module directly, so Telescope's Gate watcher never recorded an API permission check and the app's Gate callbacks had no say. $user->can() / @can disagreed with the API too: abilities were defined once from the permissions that existed at boot — a permission created later stayed denied until the process restarted (Octane, queue and Horizon workers) — super_admin_callback never reached can(), and the boot-time definitions overwrote any ability the app had defined under the same name. PermissionRegistrar now answers package permissions through a single Gate::before hook read at check time, and every package check goes through Gate. See tests/Feature/PermissionGateIntegrationTest.php.
  • GET /mcp/sse hung and advertised a URL that 404s: it announced /mcp/message without the API prefix, so no client ever connected. It now answers 405 Allow: POST on both drivers.
  • record.mcp.route_prefix made the guidance advertise routes that do not exist: the key never moved a route, yet sp_api_get_api_guidance built URLs from it. The advertised URLs now come from the registered routes.

Deprecated ​

  • The legacy MCP driver is deprecated and will be removed in 0.6.0; plan the move to record.mcp.driver = laravel. record.mcp.route_prefix has no effect and is kept only so existing config files still load.

Notes ​

  • Upgrade note — Gate now decides with the built-in module: your app's own Gate::before / Gate::after callbacks and abilities now apply to API, MCP and viewOwn decisions when permissions.enabled is on, as they always did with the module off. A Gate::before that returns true for admins now grants API access too; one returning false denies it. Gate::has('view:invoice') is no longer true for package permissions, since they are answered by the hook instead of being defined one by one.
  • Upgrade note — nested writes need the child permission: users who wrote child rows through a parent (for example invoice items through PUT /invoices/{id}) now also need the child table's create: / update: / delete: permission. Grant those permissions to the affected roles before upgrading.
  • Upgrade note — nothing to do to keep working: the default driver is still legacy; URLs, headers, tokens, error codes and tool names are unchanged. Differences you meet on the laravel driver: protocol version negotiation (2025-11-25 / 2025-06-18), JSON-RPC errors carry an HTTP 4xx/5xx status instead of 200 (body unchanged), notifications answer 202, tools/list hides tools the caller cannot use and pages past 500 tools.
  • Upgrade note — read $ref in schema tool output: clients that read actions.*.response.dataSchema, actions.*.request.payload or filters[].operators from sp_api_get_endpoint must follow a {"$ref": "#/…"} (a JSON pointer into the same result) wherever one appears; the first copy of each schema stays inline.
  • Testing: the MCP suites run on both drivers (SP_MCP_DRIVER=laravel vendor/bin/phpunit --filter Mcp); the stdio tests start vendor/bin/testbench configured by the new testbench.yaml; the OAuth tests use a Passport stand-in, so verify discovery once against a real Passport install (see docs/guide/modules/module-mcp.md). AuditLogQueuedRequestContextTest now runs its queue:work with --memory 2048: late in a long suite the PHPUnit process passed the worker's 128 MB default and the worker exited with code 12 before working a job.
  • Upgrade note — hooks and filters that now take effect: beforeRead hooks that set or removed query parameters now change results over HTTP, and grouped or/and conditions on relationship columns now match — review hooks and saved queries that relied on either being ignored. An app subclass overriding RecordService::executeCreate / executeUpdate / executeDelete must add the new trailing ?array &$outcome = null parameter.
  • Upgrade note — hooks now run for agents: hooks that assume a routed HTTP request ($request->route()) or a specific URL now also run for MCP and AI SDK tool calls; set SP_MCP_RUN_RECORD_HOOKS=false to keep the old behaviour while you adapt them.
  • Formatting: the codebase is now Rector- and php-cs-fixer-clean, so composer quality (format-check → analyse → test) passes.
  • Contributors: laravel/ai is now a require-dev package and needs PHP 8.3+, so on PHP 8.2 run composer remove --dev laravel/ai before installing; the AI tests skip themselves when it is missing. Installing it raised the dev framework to Laravel 13.34 (laravel/ai needs illuminate/json-schema ^12.62|^13.15).

[0.5.03] - 2026-09-30 ​

Security ​

  • SQL injection through X-Tenant-ID in relationship includes: the six relationship subquery builders in RelationshipResolverUtils appended the tenant filter by interpolating the value into raw SQL. For a parent table with hasTenantId: false the value comes straight from the X-Tenant-ID header, so X-Tenant-ID: 1 OR 1=1 on ?select=*,children(*) returned every tenant's related rows — and the header could carry arbitrary SQL. Every tenant value is now a bound parameter. See tests/Feature/RelationshipTenantBindingTest.php.

Fixed ​

  • viewOwn ignored how the application authorizes: the own-records check only ever asked Laravel's Gate, while authorizeAction() decides permissions through super_admin_callback, a custom record.authorization handler, the built-in permission module, or Gate. An app authorizing through a custom handler granted viewOwn there and was never restricted — its viewOwn users could list and read every row. The decision now lives in PermissionUtils::userHasAnyPermission() and PermissionUtils::isSuperAdmin(), used by both authorizeAction() copies and by OwnRecordsScope, so the three can no longer drift. Consequences fixed with it: a super admin identified by super_admin_callback is never own-restricted, even when a blanket Gate::before also grants viewOwn:*; and with the built-in module a viewOwn permission is checked against the database directly, so one created after boot applies immediately instead of waiting for a worker restart (Octane). See tests/Feature/OwnRecordsAuthorizationModesTest.php.
  • Super admins were Forbidden over MCP: McpServerService::authorizeAction() — a copy of the HTTP check — had no super_admin_callback branch at all, so a super admin allowed over HTTP got -32002 Forbidden from the MCP data tools. It now shares the HTTP decision.
  • viewOwn was enforced on list reads only: a user holding viewOwn:{pmsName} saw only their own rows in a list but could read, update, delete, restore or force-delete any row by id, and upsert over another user's row through its match_on key. Own-records scoping now lives in OwnRecordsScope and is applied — directly after the tenant filter — to by-id reads and every write builder, so bulk update/delete and the MCP tools inherit it; upsert, whose ON CONFLICT cannot carry a WHERE, refuses a colliding foreign row with 403. A refused by-id read or write is indistinguishable from a missing id. Users without viewOwn are unaffected. See tests/Feature/OwnRecordsWriteScopingTest.php.
  • Cached reads leaked across owner scopes: RecordCacheService::queryFingerprint() hashed only the query and body, so a scoped and an unscoped result shared a cache key and whoever warmed the cache first decided what every later caller saw — after an admin listed a cached table, a viewOwn user was served the admin's full list, and the reverse. The fingerprint now carries an owner-scope token for restricted callers, keyed by the config table name (list and internal reads previously passed the physical name, so a table whose config key differs from its table got no token at all); unrestricted callers keep byte-identical keys, so no cache is invalidated. See tests/Feature/OwnRecordsCacheIsolationTest.php.
  • viewOwn upsert could take over a row through any unique key: MySQL's ON DUPLICATE KEY UPDATE fires on any unique key — including the primary key — regardless of match_on, so an upsert naming a foreign row's id or another of its unique values overwrote that row. OwnRecordsScope::assertNoForeignMatches() now probes match_on, the primary key, and every unique index; SQLite/Postgres, which raised a 500 that leaked the id's existence, now return the same 403.
  • The internal/MCP read cache and cached table functions ignored the owner scope: applyRequestFilters() (behind executeGetById()/executeGetByFilter(), every MCP data tool and attachment lookups) and executeTableFunction() built cache keys without it, so the first caller's rows were served to every later one. Both now carry the owner-scope token.
  • executeUpdate()/executeDelete() emitted events for writes that changed nothing: RecordUpdated/RecordDeleted were dispatched even when the scoped write touched no row — a missing id, or a row outside the caller's tenant or own-records scope — sending audit and webhooks events for rows that never changed. They now fire only on an actual effect.
  • UUID tenants broke belongsTo includes: addBelongsToSubquery() cast the tenant id (int), which blocked the injection above on that one path but turned every UUID or string tenant id into 0, so an embedded belongsTo always came back null. The value is bound as-is now.
  • viewOwn did not apply to related rows: a row of a viewOwn-restricted table could still be embedded (?select=*,rel(*), on both the subquery and the batched loader), matched through a relationship filter (?rel.col=eq.x, an existence oracle), and served from a cached response on an unrestricted table that embedded it. The own-records scope is now applied to the related table in every relationship loader and filter, and the cache key carries a token for every restricted table the select embeds. See tests/Feature/OwnRecordsRelationshipIncludesTest.php.
  • Nested relationship writes ignored the child table's tenant and owner: processRelatedData() scoped child rows only to the parent, and a non-tenant parent passed no tenant at all, so PUT /notes/1 {widgets:[{id:2,…}]} could edit or delete another tenant's — or, under viewOwn, another user's — child hanging off a shared parent, and a created child took whatever tenant_id the payload carried. Children are now scoped by the child table's own tenant (the parent's, else the request's) and own-records rule, created children are stamped with that tenant, and attaching an existing id the caller cannot read to a many-to-many or has-many-through relationship is refused with 422. A nested write to a tenant-scoped child with no resolvable tenant is refused with 422, as the child's own endpoint already did. See tests/Feature/NestedRelationshipWriteScopingTest.php.
  • Queued audit rows lost the actor, IP address and user agent: with audit.queue_enabled, createAuditLogEntry() read user_id, ip_address, user_agent and request_id inside the queue worker, which has no HTTP request and no authenticated user. Every queued CRUD, model and auth audit row therefore stored user_id NULL and the worker's own address and agent (127.0.0.1 / Symfony, or NULL) instead of the client's; metadata.change_summary and metadata.user_name had the same fault. The request is now captured at dispatch and carried to the worker in hidden Laravel Context, so a custom audit.job_class needs no change. Synchronous auditing is unchanged. See tests/Feature/AuditLogQueuedRequestContextTest.php.
  • Service-level CRUD audit rows were written by a queue worker and without the record: RecordService::executeCreate/Update/Delete — behind the MCP tools, the attachment endpoints and application code calling them — audit through LogRecordAuditListener, which implemented ShouldQueue. On any non-sync queue connection (Laravel 11+ ships QUEUE_CONNECTION=database) it ran in a worker even with audit.queue_enabled off, so user_id was NULL and ip_address / user_agent were the worker's. It also passed only the request context as the record, so entity_id and the tenant were NULL, new_data held the IP and agent instead of the row, and update and delete entries — having no id — were never written at all. The listener is now synchronous (audit.queue_enabled alone decides queueing) and passes the record id, old/new data and tenant. See tests/Feature/RecordEventAuditTest.php.

Deprecated ​

  • record.restrict_to_own_records has never been read by the runtime and has no effect; its comment claimed it limited queries to the authenticated user's records. It is kept for compatibility and marked deprecated. Use the viewOwn:{pmsName} permission.

Notes ​

  • Upgrade notes for viewOwn under a custom record.authorization handler: the handler is now consulted for viewOwn:*, with action 'view_own' (not 'read'). A handler that decides on the permission name and grants viewOwn will now restrict those users; a handler that only switches on $action is unaffected. viewOwn grants made through Laravel's Gate keep restricting in every mode.

[0.5.02] - 2026-09-27 ​

Added ​

  • title, subject and recap are never stored empty: each audit row's human-readable columns now fall back to the best value derivable from the event and entity instead of writing a blank. recap previously came back '' on a record's first update — getOldAuditLogDate() diffs against the previous audit row rather than the live row, so there was nothing to compare against — and subject came back '' whenever none of the configured audit.subject_fields were present. Caller-supplied values always win; only blanks are filled. A generated recap still takes precedence over the fallback, so a real diff reads Updated Settings: Name rather than the bare Updated Settings.

Fixed ​

  • MCP data tools could read and write another company's rows: McpServerService::handleToolsCall() took the tenant from $args['tenantId'] — a value supplied by the model — and never consulted the request, so a caller could name another tenant's id and get their data, or omit the argument and receive every tenant's rows at once. A correct X-Tenant-ID header did not constrain either case, and with record.mcp.read_only false the same path allowed writing into another company's data. The tenant is now resolved from the request via RecordUtils::resolveTenantIdFromRequest(); a tenantId argument that disagrees is refused; a tenant-scoped table with no resolvable tenant is refused rather than widened; and tenantId no longer appears in the published tool schemas. Tables with hasTenantId: false are unaffected. See tests/Feature/McpTenantIsolationTest.php.
  • The MCP stdio server (php artisan sp-laravel-api:mcp) gained a --tenant=<id> option. A console process has no request and therefore no tenant header, so tenant-scoped tables became unreachable over stdio once the tenant stopped coming from tool arguments; --tenant scopes the whole session explicitly, and omitting it still refuses rather than widening.
  • Integer tenant IDs crashed every tenant-scoped write when auditing was enabled: record.id_type = 'integer' is a supported configuration, so the tenant column holds an int, but the audit path declared ?string $tenantId in five places while its callers pass the value through as mixed. Under declare(strict_types=1) PHP does not coerce, so PUT /{table}/{id} on any hasTenantId: true table threw TypeError and returned a 500 before the audit row could be written. Widened getOldAuditLogDate(), getAuditMetadata(), getEntityAuditLogs(), cleanupOldLogs(), and getPreviousAuditEntry() to int|string|null. The same defect existed on the webhook path — WebhookTrigger::dispatchWebhooks() and DispatchWebhookJob::$tenantId — and is fixed alongside it, since both read the tenant from an untyped context array. See tests/Feature/IntegerTenantAuditLoggingTest.php.
  • createAuditLogEntry() threw on a payload without title or entity_id: both were read with direct array access, so any caller invoking this public method without them got ErrorException: Undefined array key instead of an audit row. Both are optional now.
  • Duplicate PUT in the MCP schema's detail-endpoint methods: sp_api_list_endpoints emitted ["GET", "PUT", "PUT", "PATCH", "DELETE"] for every updatable table's {id} route — 'PUT' was appended twice in McpServerService, while the equivalent actions.update entry correctly emits ["PUT", "PATCH"]. An agent reading the schema saw the same method listed twice.
  • Userstamps ignored a configured auth guard: RecordPayloadExtractor and RecordService called auth('api') directly instead of RecordConfigService::authGuard(). An application setting sp-laravel-api.auth.guard to anything else got a null user there, so created_by_id / last_updated_by_id were silently left unstamped — no error, just missing attribution. See tests/Feature/ConfiguredAuthGuardUserstampsTest.php.
  • sp-laravel-api:agent installed unrendered Blade: installRules() copied resources/agent/guidelines/core.blade.php byte-for-byte to .agents/rules/sp-laravel-api.md, so the installed rules contained literal @verbatim / @endverbatim directives that an agent reads as content. The template is compiled now, as Laravel Boost compiled it before the templates moved. See tests/Feature/AgentSetupRendersGuidelinesTest.php.
  • Bulk endpoints were missing from the OpenAPI spec: only /bulk/upsert was emitted, so /bulk/create, /bulk/update, and /bulk/delete never reached the generated spec or the exported Postman/Bruno collections — no one could generate a working client for them. All three are documented now, gated exactly as routes/api.php registers them (record.bulk_operations plus the per-action can* flag), advertising both the envelope and bare-array bodies. See tests/Feature/OpenApiBulkPathsTest.php.

Notes ​

  • Widening a single signature is not sufficient and was verified not to be: with only getOldAuditLogDate() fixed, the identical TypeError reappears one frame later in getAuditMetadata() within the same request. The regression tests therefore drive the whole write path, not one method.
  • String and UUID tenant IDs are unaffected — the change only widens an accepted type, never narrows one.
  • Test suite: the abstract base PackageTableGovernedIdTypeTest was renamed to ...TestCase so PHPUnit stops warning about an abstract class in a *Test.php file on every run.

[0.5.01] - 2026-09-19 ​

Fixed ​

  • Bulk endpoints misparsed their own documented request body: POST /{table}/bulk/create, /bulk/update, and /bulk/delete recognised only a bare top-level array, but the docs prescribe {"data": [...]} for create/update and {"ids": [...]} for delete. A documented body was therefore treated as a single row whose only field was the envelope key, so per-row validation ran one nesting level too high and failed on every required column — e.g. {"data":[{"key":"x"}]} returned 422 "The key field is required." for a row that plainly had key. Bulk delete failed the same way with Primary key (id) is required, since no ids handling existed anywhere. The item detection in HasBulkOperations now unwraps a recognised envelope before running, so both shapes converge on the same list. RecordService::bulkRecord() (the legacy /bulk dispatcher, which accepted only {"items": [...]}) now also accepts {"data": [...]}, so every bulk route takes the same shapes. See tests/Feature/BulkEnvelopeShapeTest.php.

Notes ​

  • Existing bodies are unaffected: a bare array and the long-standing "a single object is one row" convenience both behave exactly as before — unwrapping happens before the previous heuristic, which is otherwise untouched.
  • A key is only treated as an envelope when it is the body's sole top-level key, its value is a JSON array, and the table declares no column of that name. A table with a real data column keeps ownership of it, so one row is never silently split into many.
  • /bulk/upsert was already documented with a bare array and was never broken for clients following its docs; it accepts the data envelope now too, for consistency.
  • Unrelated gap noticed while fixing this: OpenApiService emits only /bulk/upsert, so /bulk, /bulk/create, /bulk/update, and /bulk/delete are missing from the generated OpenAPI spec and from exported Postman/Bruno collections. Not addressed here.

[0.4.99] - 2026-09-17 ​

Added ​

  • Enum column extraction in SyncRecordColumnsCommand: SchemaRegistryUtils::getTableColumns() now inspects database schemas across MySQL (enum(...)), PostgreSQL (user-defined typtype = 'e' enum types), and SQLite (CHECK(col IN (...)) table constraints) to extract enum values as 'enum' => [...]. SyncRecordColumnsCommand populates these into RecordTableType::$columns, preserves existing manual enum definitions from configs, and renders compact scalar lists inline. OpenApiService now maps column enum definitions directly into OpenAPI schemas.

Fixed ​

  • Sanitize columnHiddens in RecordService::getRecord(): Single-record reads (GET /{apiPrefix}/{table}/{id}) previously omitted RecordApiResponseService::removeHiddenFields(), causing sensitive columns (like password and remember_token) and hidden columns on nested relationships to be leaked in single-record responses and committed to the query cache. Hidden fields are now properly stripped before caching and returning single records.
  • Exclude hidden columns in relational subquery projection: In RelationshipResolverUtils::resolveJsonObjectColumns(), wildcard * expansion now strips columnHiddens of the related table so hidden columns are never projected into relational JSON subqueries.
  • Sanitize columnHiddens across all mutation and event dispatch channels: Sensitive fields configured under columnHiddens and config('audit.excluded_attributes') are now unconditionally removed (unset) across all mutation channels:
    • AuditLogService completely strips hidden fields from sp_audit_logs.old_data, sp_audit_logs.new_data, and metadata.field_changes, and excludes them from generated recap messages, while still logging the event so password changes are tracked without leaking secrets or cluttering changed-field feeds.
    • AuditLogService::getFieldTimeline() and AuditLogService::getFieldStats() unconditionally return empty structures for hidden/excluded fields.
    • RecordService::fireBroadcastEvent() strips hidden fields from RecordMutated event payloads.
    • RecordService::executeCreate(), executeUpdate(), and executeDelete() strip hidden fields from RecordCreated, RecordUpdated, and RecordDeleted payloads.
    • WebhookTrigger::dispatchWebhooks() strips hidden fields from webhook delivery payloads and database logs.
    • AuditableTrait::sanitizeAuditData() automatically resolves columnHiddens from the table's schema and strips them from model audit payloads.
    • McpServerService aligns MCP with REST API capabilities: Data MCP tools (list_*, read_*, etc.) strip hidden columns from response data; Schema MCP (sp_api_get_endpoint) marks hidden columns as write-only (in: ['write'], hidden: true), omits them from read response dataSchema, and excludes them from query filters and sorts.
  • Safe-by-default MCP read-only configuration: config/sp-record.php and SetupPackageCommand scaffold template now default SP_MCP_READ_ONLY to true (env('SP_MCP_READ_ONLY', true)), enforcing a safe-by-default security posture for autonomous AI tools unless write operations are explicitly opted into.
  • Agent query performance guidance (limit vs per_page): McpServerService tools and schema guidance (sp_api_get_api_guidance) now explicitly document and recommend limit over per_page for AI queries, executing direct SQL LIMIT queries without computing expensive COUNT(*) pagination totals.

[0.4.98] - 2026-09-07 ​

Added ​

  • Opt-in OpenAPI realtime metadata: when record.broadcast_events and sp-laravel-api.openapi.realtime.enabled are both true, the generated OpenAPI 3.0.3 document includes x-sp-realtime and a RecordMutated component schema. It documents the existing private tenant channel and event contract only; it never exposes broadcaster credentials or changes runtime authorization.
  • Application OpenAPI contributions: consumers can append declared paths, components, tags, non-reserved x-* extensions, realtime channels, and container-resolved contributor classes. Contributions are config-cache safe, append-only, and rejected on invalid OpenAPI structure or package-name collisions.
  • Agentic MCP structured output and call guidance: Data MCP and Schema MCP tools now publish MCP outputSchema definitions and return JSON-safe structuredContent, while retaining JSON text content for older clients. Schema MCP adds sp_api_get_api_guidance and action-level request/response context so agents can distinguish body-less GET/DELETE calls from JSON-body writes without accessing database data.

Changed ​

  • OpenAPI filter fields are concise: generated list-operation filter parameters now state only the field and {operator}.{value} syntax. Set sp-laravel-api.openapi.filter_documentation_url to attach the full operator guide once through the standard operation-level externalDocs object.
  • Bruno and Postman exports preserve user edits by default: existing generated requests, test payloads, headers, scripts, and custom requests now remain intact while newly documented endpoints are added. Use --regen=<tag> for a targeted replacement or --force to refresh all current generated requests. Protected generated requests now use an explicit Authorization: Bearer header instead of inherited collection-level bearer authentication.

[0.4.97] - 2026-09-06 ​

Added ​

  • Optional audit.filter mutation policy with AuditLogFilterInterface, container resolution, and configuration validation.
  • AuditLogService::insertAuditLogWithContext() for explicit actor, request, and tenant context during admission, before audit preparation or job dispatch.

Compatibility ​

  • Missing/null filter preserves existing behavior. Existing customAuditLog return semantics and audit job payloads remain unchanged; no migration is required.
  • Authentication and direct low-level/trait auditing remain outside this filter. Queued lifecycle listeners evaluate the policy when they call log in the worker. See audit policy coverage.

[0.4.96] - 2026-08-21 ​

Added ​

  • RecordTableType::$ownerColumn: explicit owner column for viewOwn:* scoping, plus the record.own_records_owner_columns config key that sets the auto-detection order when a table declares no ownerColumn.

Fixed ​

  • created_by_id / created_by were rewritten on every update: RecordService::applyTimestampsAndAuditFields() looped over all five audit columns inside its $isUpdate branch, so PUT/PATCH (and the update branch of upsert) stamped the editing user into the create-time columns. This contradicted both docs/guide/features/feature-userstamps.md ("created_by* untouched on update") and RecordPayloadExtractor, whose update branch correctly omits them — the extractor's output was simply overwritten one call later. Effect: record ownership silently transferred to whoever wrote last, so an admin editing or approving a customer's row claimed it, which broke viewOwn:* scoping for any table scoped on an audit stamp and would have undone a created_by_id = user_id backfill. Create-time columns are now split out as CREATE_AUDIT_COLUMNS and written on create only; the two upsert paths fill them through the new applyUpsertCreateStamps() for their INSERT branch (mirroring the existing created_at handling) and exclude them from the upsert's update columns, so upsert-inserted rows are still stamped and upsert-updated rows keep their original author. See tests/Feature/UpdatePreservesRecordOwnerTest.php.
  • viewOwn scoping filtered on the audit author instead of the record owner: the own-records pass in QueryBuilderFiltersUtils::apply() resolved its owner column from created_by_id / created_by only. Those are audit stamps — they record who inserted the row, which is the admin, support agent, or system worker when a record is created, granted, or approved on a customer's behalf. Domain tables (purchases, subscriptions, orders, invoices, notifications, tickets, payment methods) track the owning subject in user_id, so a row with created_by_id = {admin} and user_id = {customer} was invisible to the customer who owned it — purchased content stayed locked in client apps. Resolution now tries the table's ownerColumn first, then each entry of record.own_records_owner_columns, taking the first column the table actually declares; when nothing matches, scoping is still skipped rather than erroring. See tests/Feature/OwnRecordsScopingTest.php.

Notes ​

  • The default resolution order is unchanged (['created_by_id', 'created_by']), so existing installs keep their current behaviour on upgrade. Opt in per table with ownerColumn: 'user_id', or globally with 'own_records_owner_columns' => ['user_id', 'created_by_id', 'created_by']. The default was deliberately not switched to user_id-first: on tables where user_id references a user other than the owner (the employee a review is about, a message recipient), a silent flip would expose rows a viewOwn:* holder previously could not see.
  • tests/Unit/BasicTest.php asserted the old update-rewrites-created_by* behaviour; it now pins the documented split (all userstamps on create, only updated_by / last_updated_by / last_updated_by_id on update) and was renamed accordingly.
  • last_updated_by_id / last_updated_by / updated_by are deliberately absent from the owner-column resolution order and must not be added: they move on every write, so an admin editing a customer's row would take visibility from the customer and grant it to the admin.
  • An ownerColumn that the table does not declare in columns is ignored and resolution falls through to the next candidate — it does not raise an unknown-column error.

[0.4.95] - 2026-08-18 ​

Changed ​

  • Disabled auto-creation of .agents skills/guidelines: relocated agent templates from resources/boost/ to resources/agent/ so Laravel Boost no longer automatically discovers and generates agent assets during composer install/update or boost discovery.
  • On-demand agent setup command: introduced php artisan sp-laravel-api:agent (and alias sp-laravel-api:agent-init) to install .agents/skills, .agents/rules, and configure .mcp.json on demand.
  • Service provider publish tag: added sp-laravel-api-agent publish tag for vendor:publish.

[0.4.94] - 2026-08-18 ​

Changed ​

  • Caching is now opt-in per table/function: disableCache on RecordTableType, RecordFunctionType, and the #[RecordTable]/#[RecordFunction]/#[RecordGlobalFunction] attributes now defaults to true, so endpoints are not cached unless explicitly enabled with disableCache: false (global record.cache.enabled must still be true). Previously every GET was cached by default once the global flag was on, which broke client business logic that reads fresh data. Array (legacy) configs are normalized through the same ?? true fallbacks, so old array configs also default to uncached. Tests updated to opt in explicitly.
  • RecordFunctionType::$httpMethod is validated against RecordFunctionMethodEnum: a string or array containing anything other than GET/POST/PUT/DELETE/PATCH/OPTIONS/HEAD (or the enum instance itself) now throws InvalidArgumentException. Enum values remain accepted as strings, so existing valid configs keep working.
  • RecordTableType::$public is deprecated: it is now derived automatically from isAuthRead/isAuthWrite (public read = !isAuthRead, public write = !isAuthWrite) and only honored as a legacy override when both auth flags are left at their defaults. RecordTablePublic is also tagged deprecated.

Fixed ​

  • RecordMorphToManyType::toArray() and RecordSpatiePermissionType::toArray() emitted 'type' => null (a stray never-assigned $recordRelationshipsEnum property) — both now emit the real relationship type.
  • RecordMetaHasManyThroughType::toArray() wrote the orderBy key twice — deduplicated.

Docs ​

  • Restructured: merged pagination pages into module-pagination.md, split permission/relationships/config docs into focused pages, added docs/agents_init/ setup prompts, reworked api-type-reference-and-examples.md (httpMethod enum requirement, disableCache default true, public deprecation).
  • New userstamps convention: raw audit columns created_by_id / last_updated_by_id; created_by / last_updated_by reserved as belongsTo relationship aliases.
  • PHP attribute-based config framed as legacy (record config is the recommended path); cache pages updated to opt-in semantics.

[0.4.93] - 2026-08-17 ​

Added ​

  • Direct upload for attachments (opt-in): create-upload-url and complete-upload serverless-style functions on sp_attachments, gated behind attachments.direct_upload.enabled (default false, so existing clients are byte-for-byte unchanged). On S3/R2 disks create-upload-url returns a presigned PUT URL; on local/public disks it returns the existing server-side complete-upload multipart POST fallback. Issued uploads are bound to their completion by a stateless HMAC upload token (upload_token + expires_at) verified before any folder/record lookup, and completion keys must match the issued {prefix}/{public|private}/YYYY/MM/DD/{uuid}.{ext} pattern, so callers cannot claim arbitrary existing objects. Presigned-PUT objects over attachments.max_upload_size are rejected at completion (422, object deleted). Disk selection stays visibility-based via the new AttachmentStorageService (extracted verbatim from the controller): disk_public (e.g. karunafilm-public) / disk_private (e.g. karunafilm-private).
  • S3/R2 multipart upload lifecycle (opt-in): create-multipart-upload, sign-multipart-part, complete-multipart-upload, and abort-multipart-upload for large files above attachments.direct_upload.min_multipart_size_bytes (default 100 MiB, configurable), isolated behind an AttachmentMultipartDriver contract with one S3MultipartDriver implementation (requires aws/aws-sdk-php, listed in suggest). Multipart completion takes the upload_id plus part ETags; object metadata (content type, size, ETag) is authoritative from headObject and the ETag is never persisted or returned. S3 errors surface as 422s, never raw 500s. The four functions disappear from routes/OpenAPI/exporters when direct upload is disabled.
  • Signed private preview URLs (opt-in): attachments.preview_url_enabled (default false) adds a preview_url to private attachment responses — a short-lived (default 300 s, attachments.preview_url_ttl_seconds) HMAC-signed /{api_prefix}/{attachment_prefix}/{id}/preview URL that renders in <img>/<video> tags without Bearer headers. The signature is verified before any database lookup and excludes the path, so the endpoint is not an attachment-existence oracle (bad signature → 410, even for unknown ids).
  • Docs: S3/R2 two-bucket and single-bucket root disk configuration, bucket CORS policy, and the direct-upload/preview feature flags documented in docs/guide/features/feature-attachments-visibility-access.md.

Notes ​

  • Flysystem v3 AwsS3V3Adapter never wires temporaryUploadUrl for S3, so presigned PUTs are produced via the raw S3 client (PutObject), mirroring the multipart driver.
  • Known pre-existing gap: with rpc_prefix='' the package registers no route that can dispatch {id}-pattern functions ({table}/{id}/preview → 404); the existing download/view share this trait. Use the default rpc_prefix='rpc' for id-bearing attachment functions.

[0.4.92] - 2026-08-16 ​

Fixed ​

  • sortby ordered page 1 but did not move the cursor, so cursor pagination returned a broken page 2: ordering was decided twice by two mechanisms that never consulted each other. QueryBuilderFiltersUtils::applySort() ordered the query by sortby, while RecordService::executeCursorPagination() paged on cursor_column, which defaulted to record.pagination.cursor.default_column (id). ?sortby=created_at therefore ordered page 1 by created_at and returned the last row's id as meta.cursor; page 2 then ran WHERE id <op> <that value> against unrelated keys, which is not the continuation of a created_at ordering. Nothing rejected the combination and nothing warned — an infinite-scroll list repeated some rows and silently skipped most of the result set, and which wrong rows came back varied between runs because it depended on how the random uuids compared. Reported by a client (KarunaFilm, 2026-08-16). The sort resolution is now a single function, QueryBuilderFiltersUtils::resolveSort(), used by both applySort() and the cursor block, and the cursor pages on the column the results are actually ordered by. Precedence: an explicit cursor_column wins, then an explicitly configured pagination.cursor.default_column (when set to something other than the shipped id), then the sorted column. An explicit cursor_column that differs from the sorted column now also reorders page 1, which the old code only did from page 2 onward — so the two agree whichever knob the caller used.

    This also makes created_at the effective default paging column for any table that has one, since resolveSort() already prefers it, while tables without timestamps still fall back to the primary key — something a flat config default could not express.

  • Composite cursors compared the primary key against the cursor column's value, duplicating the boundary row: with pagination.cursor.composite_enabled (default true) and a paging column other than the primary key, the tie-breaking clause compared $primaryKey against $cursor — but $cursor held the cursor column's value, so for cursor_column=created_at on a uuid-keyed table it emitted ... OR (created_at = '2024-06-01 00:00:00' AND id <= '2024-06-01 00:00:00'), a lexicographic comparison of a uuid against a timestamp on SQLite and a potential cast error on PostgreSQL. The operator was also inclusive (>=/<=), which re-served the row that ended the previous page even when the operands were right. Reported alongside the above; clients were de-duplicating by id when appending pages. A composite cursor needs both components, and the single scalar meta.cursor returned could not carry them, so the cursor is now an encoded token (c1. + base64url JSON of {"v": <sort value>, "k": <key>}) whenever the paging column is not the primary key. The tie-break compares each component against its own column and is strictly exclusive. Both key types are covered: an integer key compares numerically, a uuid lexicographically, and ORDER BY <cursor column>, <key> is applied on every page including the first, since keyset paging needs a total order or rows tied on the sort column drift between pages.

    Client-visible: meta.cursor and the X-Cursor header are now opaque when paging on a non-key column — pass them back verbatim rather than parsing or constructing them. Paging on the primary key still returns the plain key value, and a bare scalar cursor issued by an earlier version is still accepted (it routes to the simple exclusive comparison rather than the composite branch it cannot satisfy). first_cursor/last_cursor remain plain scalars and behave the same way. Set pagination.cursor.composite_enabled => false to keep scalar cursors throughout, at the cost of unstable ordering across rows tied on the sort column.

    See tests/Feature/CursorPaginationOrderingTest.php, which walks every page of an integer-keyed and a uuid-keyed table and asserts each row is returned exactly once — including a set of rows deliberately sharing one timestamp, which is the case the tie-break exists for.

[0.4.91] - 2026-08-16 ​

Fixed ​

  • Sorting or filtering a package-managed table by created_at/updated_at was silently ignored: every table config this package ships — all three attachment tables, all three webhook tables, sp_permissions, sp_roles, and sp_audit_logs — declared no timestamp columns, even though every one of the package's own migrations creates them with $table->timestamps(). QueryBuilderFiltersUtils::getAllowedColumns() builds the allow-list for sorting, filtering, select and group_by from the config's columns array alone, so an undeclared created_at made both an explicit ?sortby=created_at and applySort()'s own built-in "prefer created_at when no sort is given" default miss the allow-list. The request then fell through to $defaultOrderBy (id) and returned a 200 with no error, no warning, and no indication the sort had been dropped — on uuid primary keys the result reads as unsorted rather than merely wrongly sorted. The same allow-list governs filters, so created_at=gte.… was discarded too. Reported by a client (KarunaFilm, 2026-08-16) against GET /{prefix}/sp_attachments?sortby=created_at&order=desc; the report named the three attachment tables, and an audit of every shipped config found the same omission on all nine, sp_audit_logs included — a table read in time order almost by definition. Fixed in two layers, because either alone leaves installs broken:

    • The shipped configs now declare the columns their migrations create. This is the honest fix, but it only reaches an install that re-publishes the config: mergeConfigFrom() is array_merge(package, app), so an app that has published config/sp-attachments.php has a tables key that replaces the package's wholesale, and the corrected default never applies to it. Re-publish with php artisan vendor:publish --tag=... --force to pick it up (this also overwrites any local patch working around this bug).
    • getAllowedColumns() now recovers created_at/updated_at from the physical table when a config omits them, which is what fixes installs that published their own config copy, and any application table whose config has drifted from its migration. The recovery is deliberately limited to those two columns and is not a general "fall back to the database schema" rule: the config columns list is an allow-list, and a table that omits password_hash or api_secret is relying on it to keep that column un-filterable — a blanket fallback would turn every such column into a blind enumeration oracle via ?password_hash=starts_with.a. Timestamps carry no equivalent exposure: they are already returned in responses, already stripped from write payloads by RecordService::sanitizePayload() and RecordPayloadExtractor (unless the table sets overrideTimestamps), already skipped by DefaultValidationUtils, and already excluded from the generated OpenAPI read and write schemas. The physical-schema lookup runs only when a column is actually missing, so a correctly-declared table costs no extra query.

    Declaring the timestamps changes reads only — it does not make them client-writable, add validation rules, or alter the generated OpenAPI schemas, because every one of those paths already treated created_at/updated_at as system columns. See tests/Feature/TimestampColumnDriftTest.php, which covers the reported request end to end, asserts every shipped package config declares every column its own migration creates, and pins both the security boundary (an undeclared api_secret stays un-filterable) and the write boundary (a client-supplied created_at is still ignored).

Added ​

  • sp-laravel-api:validate now reports config/database column drift: a new check lists, per configured table, any column that exists in the database but is not declared in that table's columns. Omitting a column is a legitimate way to keep it off the query surface, so this is reported as a warning rather than an error — the point is that when the omission is accidental the failure mode is silent (an undeclared column simply cannot be sorted or filtered on, with no error at request time), which is precisely how the timestamp bug above reached production. The tenant column is excluded, since tenancy is resolved server-side and that column is intentionally never client-filterable.

[0.4.90] - 2026-08-15 ​

Added ​

  • Opt-in read-time image resizing on the attachment view endpoint: GET /{api_prefix}/{attachment_prefix}/{id}/view now accepts w, h, fit, format, and size_name query params to resize and re-encode images on the fly, gated behind attachments.read_resizing (default false, so existing clients get byte-for-byte identical responses). Dimensions are bounded by attachments.read_resizing_min/_max (defaults 32–2000; out-of-range values return 422), format is validated against attachments.read_resizing_formats, and size_name reuses the existing attachments.image_sizes map. Non-image attachments are always served untouched. Optional caching layers: attachments.read_resize_cache_max_age (> 0 adds Cache-Control: public, max-age=N) and attachments.read_resize_cache (write-back caching of derived files on attachments.read_resize_cache_disk with read_resize_cache_ttl_minutes). Uses the package's existing intervention/image dependency, so it works on Laravel 12 and 13 alike. See docs/guide/features/feature-attachments-read-resizing.md.
  • New docs: own-records scoping section in the permission guide, a userstamps reference page (feature-userstamps.md) covering created_by/created_by_id/updated_by/last_updated_by/last_updated_by_id, and audit/userstamps cross-links. The Laravel Boost skill (sp-laravel-api-development) now instructs agents to read the shipped docs guide before source code.

Fixed ​

  • viewOwn own-record scoping hardcoded created_by and threw 500 for tables using created_by_id: the own-records permission pass in QueryBuilderFiltersUtils::apply() always added WHERE {table}.created_by = {user} even though the package's own userstamp write path auto-fills created_by_id when a table declares it (and the docs list it among the detected audit columns). A table declaring only created_by_id therefore got its rows stamped correctly on write, then blew up with SQLSTATE[42703]: Undefined column on every list request by a viewOwn:* holder. The scoping pass now resolves the owner column from the table's declared schema — created_by_id when present, created_by as the legacy fallback — and skips scoping entirely when neither exists instead of erroring. See tests/Feature/OwnRecordsScopingTest.php.
  • Single-record reads cached the wrong shape and broke cache-hit serving: RecordService::applyRequestFilters() stored data in the query cache before applying the $isArray=false narrowing, so the first request (a list) poisoned the cache and later single-record reads (e.g. AttachmentUploadController::serveFile) received a one-element list instead of the record itself, producing "Undefined array key" errors on cache hits while cache misses worked. The narrowing now happens before the cache write, and the cache key carries a :shape:list|single discriminator so the two response shapes can never share an entry. Stale list/single entries from previous versions expire naturally via TTL; php artisan cache:clear removes them immediately.

[0.4.89] - 2026-08-14 ​

Fixed ​

  • A request to a real endpoint with the wrong HTTP verb was reported as a missing dynamic table, sending people to debug route registration instead of the verb: the {table} route pattern was the catch-all [a-zA-Z0-9_\-]+, so it matched any single path segment — including segments this package itself registers as literals (docs, mcp, and the configured rpc prefix). Because the dynamic CRUD routes are registered after those literals, a request that matched a literal path but not its method fell through to {table}/{id} and the route-model binding aborted with Dynamic Table [mcp] not found. (CoreSpLaravelApiProvider). Reported by a client whose agent tooling called GET /api/v1/mcp/schema when only POST mcp/schema is registered: the 404 named a missing table, so the reporter went looking for an unregistered route across two environments before filing, when the actual problem was the verb. {table} now carries a negative lookahead excluding those reserved segments, so Laravel answers 405 Method Not Allowed for a real path with the wrong verb and a plain 404 for a genuinely unknown one. The lookahead is anchored on the segment boundary ((?:/|$)) rather than a bare $, because inside the compiled route regex $ means end of the whole URI — (?!mcp$) would still have admitted mcp/schema — and boundary-anchoring also keeps a legitimately-named table such as mcp_logs routing normally. The Dynamic Table [x] not found. diagnostic is unchanged for an actual unknown table, which is the case it was written for. See tests/Feature/ReservedRouteSegmentTest.php.

[0.4.88] - 2026-08-13 ​

Added ​

  • Laravel Boost guidelines/skill updated for filter and payload validation: resources/boost/guidelines/core.blade.php and resources/boost/skills/sp-laravel-api-development/SKILL.md — auto-loaded by Laravel Boost into AI coding assistants working in a project that installs this package — predated this release's filter-syntax, unknown-field, and nested-relationship-write changes and said nothing about any of them. Both now document the {column}={operator}.{value} filter syntax (and the bracket-style mistake to avoid), that an unknown payload field/relationship returns 422, the single-request nested-relationship-write pattern, and that a disallowed allowCreate/allowUpdate/allowDelete operation is rejected rather than silently dropped.
  • Create/update payloads now reject unknown fields: a top-level payload key that matches neither a real column nor a declared relationship alias used to be silently dropped — by RecordPayloadExtractor/sanitizePayload for scalar keys, by RelationshipResolverUtils::processRelatedData() for relationship-shaped array keys — so a typo like {"custommer_id": 5} saved the record as if that field had never been sent, with a 200/201 giving no indication anything was wrong. RelationshipResolverUtils::validatePayloadFields() now runs at the top of RecordService::createRecord()/updateRecord()/upsertRecord() (and therefore every bulk/upsert/MCP path that funnels through them) and throws InvalidArgumentException → 422 naming the bad field and listing every valid column and relationship for that table, e.g. Unknown field 'custommer_id' in payload for table 'orders'. Valid columns: ..., customer_id, .... Valid relationships: customer, items.. A column listed in that table's columnWriteDisabled is still a known field and is silently ignored as before (e.g. round-tripping a GET response back as a write) — only names matching nothing on the table are rejected. Fixed alongside this: HasCrudOperations/HasBulkOperations built the create/update/upsert/bulk payload from Request::all(), which merges the query string into the body — a ?select=.../?match_on=... on a write request was therefore landing in the payload and would have been misidentified as an unknown field; those call sites now use $request->except(array_keys($request->query())) instead. The mixed-operation bulk endpoint's per-item operation control key (create/update/delete/upsert) is likewise stripped from the item before it reaches the new check, since it was already being silently dropped as a non-column, not a real field to persist. See the "Unknown field/relationship names are rejected" note in docs/guide/api/api-crud-operations.md and the updated create_{table}/update_{table} MCP tool descriptions.
  • Upsert Support: Added POST /{table}/upsert and POST /{table}/bulk/upsert endpoints for atomic create-or-update operations.
  • Match On Parameter: Required match_on query parameter for upsert operations to define matching columns dynamically.
  • Configuration: Added $canUpsert to RecordTableType to control upsert endpoint availability (default: true).
  • OpenAPI: Updated OpenAPI generator to include single and bulk upsert endpoints with schema definitions.
  • API Client Exporters: Two new Artisan commands to export the OpenAPI spec to API client collections.
    • sp-laravel-api:export-bruno writes a Bruno collection folder (api-client/bruno/) with bruno.json, collection.bru, one subfolder of .bru files per table/RPC tag, and an environments/Local.bru environment (baseUrl/apiPrefix from config('app.url')/config('record.api_prefix'), bearerToken as a secret var).
    • sp-laravel-api:export-postman writes a Postman v2.1 collection to api-client/postman/collection.json.
    • Both commands support --output=<path>, --regen=<list|all> (case-insensitive against OpenAPI tags; rpc is a wildcard that regenerates every RPC-prefixed folder at once, e.g. RPC, RPC - Auth, RPC - Media), and --dry-run.
    • Both commands are diff-aware: existing requests are skipped unless listed in --regen; new requests are added.
    • RPC endpoints are grouped into one folder per real OpenAPI tag (RPC, RPC - Auth, RPC - Media, ...) instead of a single collapsed RPC folder, matching the tags already shown in the docs UI.
    • Each generated request's auth requirement reflects the table's isAuthRead/isAuthWrite or function's isPublic flag: public endpoints render auth: none (Bruno) / "auth":{"type":"noauth"} (Postman) instead of always inheriting the collection's bearer auth.
    • If config('record.api_docs.login_api') matches a generated RPC request's path, that request gets an auto-generated script (Bruno script:post-response, Postman "test" event) that captures the access token from the response (same key-search algorithm as the docs UI's login proxy) and writes it to the bearerToken variable — run "Login" once and every other request in the session is authenticated.
    • See docs/guide/modules/module-api-clients.md for full usage.
  • Relationships: Added RecordMorphHasManyType for polymorphic one-to-many relationships (morphMany, no pivot table) — a related table with a discriminator column (e.g. target_type) and FK column (e.g. target_id), scoped per parent table via morphClass. Supports nested writes; client-supplied discriminator values in the payload are always overridden server-side. See docs/core-concepts/relationships.md (Morph Relationships) and docs/guide/api/api-type-reference-and-examples.md (Type Reference).
  • Config directory autoloading: config/sp-record.php now ships with 'autoloaded' => true and scans its own records/tables and records/global-functions directories at config-merge time via RecordConfigLoader, so php artisan config:cache bakes those directories' contents into the cache and RecordConfigService skips its own runtime scan (config('record.autoloaded') gates the fallback). Caveat: every value reachable from those directories must be var_export()-able for config:cache to work. A Closure createValidator/updateValidator/deleteValidator on a RecordTableType makes config:cache fail — pass ['class' => MyValidator::class, 'functionName' => 'validate'] (or new RecordValidationType(...)) instead. Note this is not caused by autoloaded and turning it off does not avoid it: config/records/ sits inside config/, and Laravel's own LoadConfiguration globs config/ recursively, loading config/records/tables/orders.php under the framework key records.tables.orders regardless of this package. The package's own scaffold (sp-laravel-api:setup) generates config/records/tables/users.php using the class-reference form for exactly this reason. The directory the scan reads is bound to the same $tableConfigPath value the table_config_path key reports, so a custom table_config_path is honoured under autoloaded. See docs/guide/features/feature-record-data-types.md.
  • MCP schema now advertises single-request nested relationship writes: sp_api_get_endpoint's includes[] previously only listed a relationship's name/type/table/foreignKey, so an AI agent had no schema-driven way to know it could write a parent record and its related rows in one create_{table}/update_{table} call instead of a separate request per child table. Each entry now carries writable (true for hasMany, belongsToMany, hasManyThrough, morphMany, morphToMany, morphByMany, spatiePermission; false for belongsTo, hasOne, hasOneThrough, morphTo, morphOne), allowCreate/allowUpdate/allowDelete (for writable relationships), and a payloadHint naming the exact shape to send (e.g. "items": [1, {"id": 2}, {...fields}, {"id": 5, "_delete": true}], or the root FK field for non-writable ones). The sp_api_get_endpoint, create_{table}, and update_{table} tool descriptions now point agents at this before they write related data. Mirrors the existing "Relationship Write Payload Guide" in docs/core-concepts/relationships.md and the per-table guide OpenAPI already generates for create/update/upsert operations (OpenApiService::generateRelationshipDescription()) — this closes the gap for MCP-only agents that never load the full OpenAPI spec. See docs/guide/modules/module-mcp.md ("Writing related data in a single call").

Changed ​

  • ⚠️ Breaking — Disallowed nested relationship writes now return 422 instead of silently succeeding: sending a nested relationship item that allowCreate/allowUpdate/allowDelete: false disallows (e.g. {"id": 5, "_delete": true} on a relationship configured allowDelete: false) used to be silently dropped — the parent write still committed and the response was a plain 200/201 giving no indication the nested operation never happened. RelationshipResolverUtils now throws InvalidArgumentException → 422 naming the relationship, the action, and the disabled flag (e.g. Cannot delete item in relationship 'tasks' for table 'projects': allowDelete is disabled for this relationship.), and — since this runs inside the same DB transaction as the parent write — the entire request is rolled back, not just the disallowed nested item. This applies across all three nested-write code paths: plain hasMany/morphMany, belongsToMany/morphToMany (pivot attach/detach/update), and hasManyThrough. Also now rejected: _delete/_destroy on an item that omits the related table's primary key (ambiguous — there is nothing to identify which row to delete), previously silently skipped. Not affected: re-sending an already-linked belongsToMany/morphToMany item with no pivot fields to change is still a no-op regardless of allowUpdate, since nothing would actually change. If you rely on the old silent-partial-success behavior anywhere, update those call sites to stop sending operations the relationship doesn't allow, or set the relevant allow* flag to true. See tests/Feature/RelationshipWritePermissionEnforcementTest.php and the "Relationship Write Payload Guide" note in docs/core-concepts/relationships.md.
  • Config filenames renamed to sp-*: the package's five previously-unprefixed config files — record.php, permissions.php, audit.php, attachments.php, webhooks.php — are now published as sp-record.php, sp-permissions.php, sp-audit.php, sp-attachments.php, sp-webhooks.php, so it is obvious at a glance which files in config/ belong to this package. Nothing breaks and no action is required: only the filename changed, not the config namespace. record is publish-only and was never merged with mergeConfigFrom() (that is unchanged); the other four are still merged under their original keys (permissions, audit, attachments, webhooks) from the package's own vendor-shipped sp-*.php, exactly as they were merged from the package's unprefixed files before. Either way, config('record.tables') and every other existing call site keeps resolving unchanged. If you already published one of the old-named files, Laravel loads it under its own basename as before (e.g. config/record.php under key record) and nothing else needs to happen for it to keep working. If instead you (or a future publish) put a new-named file alongside it, ConfigNamespaceBridge::adopt() folds that sp-*-keyed value into the canonical key before the package's own merges run — the sp-* file's values take precedence over the old file's for any key both set, which is also why publishing a fresh, uncustomized sp-* file next to a customized old one is unsafe until you've copied your customizations across (see the upgrade guide). mirror() then copies the resolved canonical values back onto the sp-* key in boot(), so a migrated client can read either name. Console commands log an info-level deprecation notice for any old-named file still present, on every Artisan invocation (not one time) — gated on runningInConsole(), so it never fires on web/API requests; suppress it with sp-laravel-api.suppress_config_rename_notice. See docs/getting-started/upgrade-0.4.80-to-0.4.82.md.
  • Configurable ID Type: New record.id_type setting ('integer' default, or 'uuid') governs the primary keys of the package's own sp_permissions, sp_roles, sp_attachments, sp_attachment_folders, sp_webhook_endpoints, sp_webhook_subscriptions, sp_webhook_deliveries, sp_audit_logs, and the three pivot tables' surrogate ids (sp_role_permissions.id, sp_model_has_roles.id, sp_model_permissions.id), plus every foreign key referencing those tables. Pick it before the package migrations first run; it is not safe to change afterwards (see the comment in config/sp-record.php). sp_attachment_links.id stays an auto-incrementing integer regardless — nothing references it. See docs/guide/features/feature-record-data-types.md.
  • ⚠️ Breaking — Attachments and webhooks default id type: sp_attachments, sp_attachment_folders, sp_webhook_endpoints, sp_webhook_subscriptions, and sp_webhook_deliveries used to always use uuid primary keys, regardless of record.id_type. They now follow that setting like every other governed table, and its default is 'integer'. Any install that has not explicitly set record.id_type will get bigIncrements keys for these tables on its next fresh migration instead of uuid. If you rely on the existing uuid schema, set 'id_type' => 'uuid' in config/sp-record.php before that migration runs. Already-migrated environments are physically unaffected either way.
  • Client Reference Columns: The four columns that point at arbitrary client-owned models — sp_model_has_roles.model_id, sp_model_permissions.model_id, sp_audit_logs.entity_id and sp_audit_logs.user_id — are now varchar(191) instead of unsignedBigInteger, so a project whose User (or any audited model) has a uuid primary key can actually store its key. config/sp-audit.php declares entity_id and user_id as string to match.
  • ⚠️ Migration — existing installs: 2026_08_05_000000_convert_client_reference_columns_to_string ALTERs those four columns in place so an already-migrated project's schema matches the metadata the package now ships. It is a no-op on a fresh install and when a table is absent (e.g. sp_audit_logs with audit.enabled false), and it does not drop or recreate any index. Plan for it on large tables: sp_audit_logs is usually the biggest table in the schema and MySQL rewrites the whole table for this change, holding a metadata lock for the duration; PostgreSQL takes an ACCESS EXCLUSIVE lock and rewrites too. Run it in a maintenance window, or use an online-schema-change tool and mark the migration as run. down() is intentionally a no-op — a uuid cannot be cast back into a bigint without destroying data.
  • ⚠️ Breaking — Attachments: Renamed the sp_document_folders table to sp_attachment_folders. The deprecated sp_document_folders config alias was added during the transition and has since been removed, so the generic /{table} CRUD route under the old name now 404s — update any URL using it. The /folders and /folders/{id} endpoints are unaffected since they never exposed the table name in the URL. See docs/getting-started/upgrade-0.4.80-to-0.4.82.md and docs/guide/features/feature-attachments-folders.md.
  • Table and global-function directory scans are now cached per worker process: config/records/tables/*.php and config/records/global-functions/*.php (and legacy records/globalFunctions/*.php) are scanned once per process instead of on every call, then reused for the life of that process — the same caching behavior every other Laravel config file already has. PHP-FPM users see no difference: FPM's shared-nothing request lifecycle resets all static state at every request boundary, so the cache never survives past the request that built it. Octane and queue workers: these keep static state alive across requests, so adding, removing, or editing a file in one of these directories is not picked up until the worker restarts. This matches how config caching already behaves for every other config file in a Laravel app — these directories are no longer a special case that re-reads the filesystem live.
  • ⚠️ Every existing cached entry is orphaned by this release's cache-key and namespace-token changes — recommend php artisan cache:clear on deploy for the database/file cache stores. The fixes above change the shape of every generated cache key (new queryFingerprint/query_fingerprint components) and of the namespace token (resolveNamespaceToken()'s record branch, the widened dependency-token hash). There is no key registry and every entry is TTL'd, so this is a one-time cold cache with no correctness risk — but on the database and file stores, orphaned rows are not proactively reclaimed; they sit until something happens to touch (and expire) that exact key, or until a manual cache:clear. Redis/Memcached need no action beyond the normal memory-pressure eviction they already do.
  • ⚠️ Requests carrying search, filter, or where are no longer cached at all — a deliberate hit-rate reduction, not a bug. Before this release those three params were already included in the cache key (so no correctness problem existed), but the admission guard's has()-is-ALL-of bug meant they were, in practice, cached far more often than intended. Now that the guard is fixed to hasAny(), any one of those params correctly makes the request uncacheable — which, if list/search traffic carries any of them on a meaningful share of requests, is a real drop in cache hit rate on that traffic, visible on a latency/hit-rate graph with no corresponding error. This is the correct and conservative behavior (dynamic, high-cardinality query shapes are exactly what a cache should not retain), but it should not be a surprise discovered after the fact.
  • Global-function responses (executeGlobalFunction) now honour record.cache.admission (only_actions, except_actions, skip_query_params) — previously they bypassed all three and only checked the enable flag and dynamic-query guard. An existing admission config written with only table-scoped requests in mind may now silently stop caching some global functions that were being cached before; review only_actions/except_actions/skip_query_params against your registered global functions if you rely on their cache.
  • Cache keys now fragment on query-string shape in ways they previously didn't: tracking/cache-busting params (?utm_source=..., ?_=<timestamp>) and value-representation differences (?with_trashed=true vs ?with_trashed=1) each now produce their own cache entry, because the new queryFingerprint() folds the entire query (and JSON body) into every key rather than only the parameters each code path explicitly reads. record.cache.admission.skip_query_params remains a rejection list, not a stripping list — naming a high-cardinality param there makes matching requests uncacheable entirely rather than normalizing them into a shared entry; it trades fragmentation for no caching, deliberately, since this package has no request-normalization layer to strip params before hashing.
  • New public Sopheak\Core\Support\CacheRequestContext replaces request-attribute-based scratch space for memoized namespace versions and cache stats — request-scoped in HTTP (reset on RouteMatched), and forgotten between queue jobs by Laravel's own forgetScopedInstances(). Any long-running loop outside both of those (a custom Artisan command, a persistent worker) must call app(CacheRequestContext::class)->reset() once per iteration to avoid serving a memoized namespace version past the point another process has bumped it; the package does this itself in src/Console/McpServerCommand.php's stdio read loop.
  • clearCacheTables (on a RecordFunctionType/table config) must name the table's schema-registry key, not necessarily its physical table name — RecordCacheService::functionCacheDependencies() resolves per-table tenancy through the alias-aware SchemaRegistryUtils::getTable(), but the namespace it depends on is keyed by the raw declared string. If a table is registered under an alias and clearCacheTables names the physical table instead of the registry key, the dependency points at a namespace no CRUD write on that table ever bumps, and the function's cache silently never invalidates from that table's writes. Always use the same key you'd pass to RecordService/the route, not the underlying ->table value.
  • BC note: generateOptimizedCacheKey(), generateCursorCacheKey(), generateRecordCacheKey(), generateTableFunctionCacheKey(), and generateGlobalFunctionCacheKey() on RecordCacheService/RecordService all gained a new trailing, optional string $queryFingerprint = '' parameter. Any subclass overriding one of these methods must add the same trailing parameter (with the same default) to its override, or PHP raises a fatal signature-compatibility error on that class.

Fixed ​

  • Nested relationships silently resolved to null whenever a column-level select projection was used: switching a query from select=* to an explicit projection (the documented way to trim a response) made relationships come back null/empty with no exception and no log entry, while the identical query shape using * worked — so it read as an intermittent data bug rather than a projection bug. Three separate places dropped a join key, all the same shape: an explicit column list losing the column a later step matches or groups on. (a) RelationshipResolverUtils::includeRelationshipsRecursive() — a relation carrying nested children was loaded with only its own requested columns, so the children had no parent-side key to match on; video(id,title,profile_image(*)) loaded videos without profile_image_id, the child lookup collected an empty set of match values, and the attachment resolved to null (this is the missing-thumbnail symptom the reporter saw). It now appends the parent-side key each child matches on — a belongsTo child's foreign_key, a hasMany/morphMany child's local_key. (b) RelationshipResolverUtils::loadStandardRelationshipOptimized() — child rows were fetched without the column they are grouped back to their parents by, so every fetched row missed the grouping map and the relation returned empty; translations(locale,field,value) dropped target_id. The grouping key (ownerKey for belongsTo, otherwise foreignKey) is now always selected. (c) QueryBuilderFiltersUtils::apply() — the root query was restricted to exactly the requested columns, so a top-level include matched on a FK/local key that was not present in the row at all ($record[$config['foreign_key']] ?? null → null, collapsing the whole include). A new augmentMainSelectWithIncludeKeys() appends each top-level include's root key column. Wildcard queries were never affected because * selects every key column — that asymmetry is why the bug appeared and disappeared depending on which level happened to use *. Note on the projection contract: the augmented key columns are added to the SQL projection but are not stripped from the response, so a request like ?select=title,comments(id,body) now also returns the root id. See tests/Feature/SelectProjectionJoinKeyTest.php, which reproduces all three against the PHP-side resolver (subquery optimization pinned off, since that path selects its own keys and masks every case) and asserts each relation is populated rather than that the request merely returned 200 — the failure mode is a silent null, so a status assertion would pass against the bug.
  • POST /{table}/upsert and POST /{table}/bulk/upsert returned a 500 (NOT NULL constraint) when inserting a new row on a uuid-keyed table: RecordService::upsertRecord() and the independently-implemented bulkUpsertRecord() both call sanitizePayload(), which unconditionally unset()s id, then hand the result straight to DB::table(...)->upsert(...) with no primary key at all. A uuid('id')->primary() column has no database default and does not auto-increment — createRecord() already generates one in this situation (and restores an explicit client-supplied id), but neither upsert path ever did, so the INSERT branch (no existing row matches match_on) failed outright; even the update branch failed on some drivers, since the INSERT half of an upsert statement must satisfy NOT NULL constraints for its column list regardless of which side the conflict resolution ultimately takes. Both methods now restore an explicit primary key from the payload if given, or generate one when the column is uuid-typed and absent — harmless on an actual update, since $pk is already excluded from the columns the ON CONFLICT/ON DUPLICATE KEY UPDATE clause touches. bulkUpsertRecord() also gained the RelationshipResolverUtils::validatePayloadFields() call every other write path already had — it was the only one reimplementing the write flow inline instead of delegating to createRecord()/updateRecord()/upsertRecord(), so it was the one path where an unknown field silently passed through instead of a 422. See tests/Feature/UpsertUuidPrimaryKeyTest.php.
  • Schema MCP was blind to 8 of a table's 13 real endpoints: sp_api_list_endpoints and sp_api_get_endpoint — the two tools a frontend AI agent (no source access, unlike a backend project working directly in this repo) relies on to learn what an endpoint can do — only ever built list/read/create/update/delete. POST /{table}/upsert, POST /{table}/{id}/restore, DELETE /{table}/{id}/force, and all four bulk routes (bulk, bulk/create, bulk/update, bulk/delete, bulk/upsert) are real routes (routes/api.php) with zero representation in either tool — not "the syntax is unclear," the operation was invisible. Both tools now include them, gated exactly the way the routes/controllers themselves are gated (HasControllerHelpers::isUpsertEndpointEnabled() etc.): upsert/bulkUpsert on canUpsert, restore on canUpdate && softDeletes, forceDelete/bulkDelete on canDelete, bulkCreate on canCreate, bulkUpdate on canUpdate, bulkMixed on all three of canCreate/canUpdate/canDelete, and every bulk* action additionally on RecordConfigService::bulkOperationsEnabled(). Each new sp_api_get_endpoint action carries a note on its non-obvious part (e.g. upsert's ?match_on=col1,col2 requirement, the bulk item-count cap, the mixed-bulk operation field). See tests/Feature/McpSchemaEndpointCoverageTest.php and the updated "Schema Tools" section in docs/guide/modules/module-mcp.md.
  • The OpenAPI spec never structurally declared filter/select/sort/search query parameters — only prose did: the GET /{table} operation's parameters[] contained pagination and the tenant header only; select, sortby, order, search, and every column's filter syntax existed solely inside one long free-text description string. A schema-driven client or agent — which is what OpenAPI parameters[] exists for — had no structured way to learn the {column}={operator}.{value} filter format, and a plausible failure mode (confirmed in practice) is guessing an unsupported convention like filter[column]=value instead. OpenApiService::paths() now emits a real parameters[] entry per table column (type string, example such as eq.value/gte.100/gte.2026-01-01 depending on column type, description pointing at the operator list and warning against bracket syntax) plus select, sortby (enumerated against the table's actual columns), order (asc/desc), and search (naming the table's configured searchable fields). Also fixed while touching this: those parameters — including pagination, which was already present — were declared at the path-item level (a sibling of get/post), which per OpenAPI semantics means they formally also applied to the POST (create) operation on the same path; page/cursor/select/filters on a create request never made sense. They now live on the get operation only; the tenant header (relevant to both list and create) stays at the path level. ApiClientExportService::parsePaths() explicitly skips the new select parameter when generating Bruno/Postman collections (unchanged behavior — it has no sensible placeholder value and is already covered by the "Tip:" note in each GET request's description), while the new filter/sort/search parameters flow through into exports like any other query parameter. See tests/Feature/OpenApiTest.php (it_declares_a_structured_filter_parameter_per_column_on_the_list_operation, it_declares_select_sortby_order_and_search_as_structured_parameters, list_only_parameters_do_not_leak_onto_the_create_operation).
  • allowCreate: false on a hasMany/morphMany relationship was not enforced while creating the parent record: RelationshipResolverUtils::processRelatedData() gated nested child inserts with elseif ('create' === $operation || $allowCreate) — so during a parent create, a nested child was always inserted regardless of allowCreate, and the flag only actually took effect on parent update. A relationship configured allowCreate: false specifically to prevent clients from creating child rows through the parent endpoint therefore didn't, for the single most common case (creating the parent with nested data in the same request). Fixed by dropping the 'create' === $operation bypass so allowCreate gates nested creation the same way regardless of the parent operation. belongsToMany/morphToMany (processBelongsToManyOperation()) and hasManyThrough (processHasManyThroughOperation()) were not affected — they already gate purely on the flag. See tests/Feature/RelationshipAllowCreateOnParentCreateTest.php.
  • sp-laravel-api:setup could silently revert a customized config: the command ran vendor:publish --tag=sp-laravel-api-config --force unconditionally. On a project that had customized config/record.php and not yet renamed it, that published config/sp-record.php holding the packaged defaults — which take precedence over the old-named file for every key both set — so record.api_prefix reverted from a customized value to api/v1 and record.id_type from uuid to integer, while config/record.php stayed byte-identical on disk (nothing in git diff, no error, and the command printed "Skipped (exists)" for the file it had just superseded). The command now refuses to run while any old-named config file is present, naming each file and the git mv that resolves it; --force remains an explicit opt-in and warns instead of aborting. vendor:publish's own --force now follows the command's flag rather than being pinned on, and --force no longer overwrites the just-published packaged sp-record.php/sp-audit.php/sp-attachments.php/sp-webhooks.php with the command's own minimal scaffold — so --force genuinely means "the packaged defaults win" instead of leaving a hybrid with the autoloaded key and the RecordConfigLoader calls stripped out.
  • A custom record.table_config_path was ignored under autoloaded: config/sp-record.php hardcoded records/tables in its RecordConfigLoader::tables() call while declaring table_config_path separately, and 'autoloaded' => true disables the runtime scan that is the only other reader of that setting. A project pointing table_config_path at another directory therefore loaded no table configs at all — every CRUD route those tables defined 404'd — while sp-laravel-api:record, sp-laravel-api:sync-record-columns and sp-laravel-api:generate-record-tables-from-db kept writing into the directory nothing read. Both now derive from a single $tableConfigPath binding in the file.
  • sp-laravel-api:validate failed on a correctly-migrated install: it checked only the old unprefixed filenames, so a project that had completed the rename got four ❌ Missing config file errors and a non-zero exit from the command the upgrade guide tells you to run to confirm the rename worked — and --fix called sp-laravel-api:setup, which could never converge. It now accepts either filename (warning, not erroring, about a deprecated one), checks sp-permissions.php which was missing from the list entirely, and no longer requires cursor_pagination.php, a file this package has never shipped.
  • The config-rename deprecation notice made a false promise: it said the old-named file "keeps working" even when the sp-* counterpart was also present — in which case the sp-* file wins every shared key and the old file is no longer fully in effect. That case now logs a warning naming the consequence.
  • Creating uuid-keyed package records returned a 500: POST /{apiPrefix}/sp_webhook_endpoints, POST /{apiPrefix}/sp_webhook_subscriptions, POST /{apiPrefix}/sp_webhook_deliveries and POST /{apiPrefix}/sp_attachment_folders failed with SQLSTATE[23000] … NOT NULL constraint failed (PostgreSQL 23502, MySQL 1364/HY000) whenever the client omitted id. (sp_attachments was not affected through the CRUD route — it sets canCreate: false, so that path returns 404 — but its declaration is corrected too, for nested writes and upserts.) These tables use uuid('id')->primary(), which has no database default and does not auto-increment, but config/sp-webhooks.php and config/sp-attachments.php declared id as string — and the declared type is what tells the package to generate a key. They now declare uuid, so the key is generated and the request succeeds. Supplying your own id works as before. sp_attachment_links is unaffected: its id is auto-incrementing and is correctly declared integer.
  • Folder queries failed with a missing-table error on installs whose default connection is named sqlite: the sp_document_folders → sp_attachment_folders rename migration carried a guard that read config('database.default') — a connection name — and compared it against 'sqlite', a driver name. Stock Laravel 11/12 ships 'default' => env('DB_CONNECTION', 'sqlite') against a connection key of that name, so on those installs the rename silently did not run while the config already pointed at sp_attachment_folders, and every folder query failed. The guard is removed: the rename works on every supported driver and now always runs when the old table is present and the new one is not. If you already worked around this by renaming the table by hand, the migration detects that and does nothing.
  • A natural string primary key could be overwritten with a generated uuid: on SQLite, uuid detection treated any introspected varchar/char column as a uuid. A table registered in record.tables without declared columns whose primary key is a natural string key (for example string('sku')->primary()) therefore had its key replaced by a generated uuid on create, silently corrupting the row. The SQLite-only widening is removed, so a bare varchar/char is no longer treated as a uuid on any driver. Action required, SQLite only: undeclared columns are still filled by live introspection, and uuid() introspects as char(36) on MySQL and uuid on PostgreSQL — both still detected — but as bare varchar on SQLite. So on SQLite a uuid-keyed table registered without declared columns no longer gets its key generated; declare 'id' => ['type' => 'uuid'] in that table's columns to restore it. MySQL and PostgreSQL users need no change. Declaring 'string' or 'varchar' does not generate a key on any driver.
  • Bracket-style filters crashed with a 500 instead of a clear error: GET /{table}?filter[column][operator]=value — a syntax this API has never supported; filters are {column}={operator}.{value} top-level query params, not wrapped in a filter[...] key — crashed with ErrorException: Array to string conversion in QueryBuilderFiltersUtils::executeOperatorsOptimized()/executeOperators() whenever the bracket nesting was two levels deep (e.g. filter[created_at][>=]=2026-08-01); a single level deep (e.g. filter[status]=eq.open) silently filtered nothing, with no error at all. Both shapes now return a 422 naming the exact query parameter and the corrected syntax, e.g. Invalid filter for 'filter': bracket-style filters (e.g. filter[operator]=value) are not supported. Use and=(created_at.gte.2026-08-01T00:00:00.000,created_at.lte.2026-08-31T00:00:00.000) instead.
  • MCP schema advertised filter operators the API doesn't accept: sp_api_get_endpoint's filters[].operators (from McpServerService::filterOperatorsForType()) returned SQL-style symbols (=, !=, >, <, >=, <=) instead of the real {operator}.{value} tokens (eq, neq, gt, lt, gte, lte, ...) — an AI agent following the schema literally would build a request the filter engine rejects. It now returns the real tokens, and list_{table}'s queryParams tool description spells out the {column: "operator.value"} syntax with an example and warns against filter[...]/bracket nesting. docs/guide/api/api-audit-management-endpoints.md, docs/guide/modules/module-ai-sdk.md, and docs/guide/modules/module-mcp.md had stale examples using the unsupported bracket form corrected to match; docs/guide/api/api-crud-operations.md and the generated OpenAPI info.description (OpenApiService) both gained a "common mistake" note about it.
  • Record API cache invalidation had six separate correctness gaps that together meant a write frequently left stale data behind it, and a search/filter/where request was cached when it should never have been: QueryCacheService::bumpNamespace() deduplicated repeated bumps to the same namespace within a single request/process, so a legitimate second write touching the same namespace in one request (e.g. a bulk operation, or two calls in one job) silently had its invalidation dropped — the dedupe map is removed and every bump now reaches the store's counter unconditionally. resolveNamespaceToken()'s record-scope branch never folded the table's own namespace versions into the record token, so clearTableCache()/a bulk write bumping table scope never invalidated an already-cached record_show entry for that table — a single-record cache could serve pre-write data until TTL even though the table-level clear had run; the record token now also composes the table's global/tenant versions, and this incidentally fixes bulk writes (HasBulkOperations, RecordService::bulkCreateRecords/bulkUpdateRecords), which only ever bumped table scope and never record scope. record_cursor: keys were never matched by parseScopeFromKey() at all, so they fell through to a hardcoded v1 token that no namespace bump could ever reach — cursor-paginated list caches were stale until TTL, always, with no invalidation path whatsoever. Read-shaping query parameters (with_trashed, only_trashed, add_total, and anything read via Request::input()/boolean() rather than the route) were absent from every cache key, so GET /{table}/{id}?with_trashed=true and the plain GET /{table}/{id} could collide on one cache entry — a soft-deleted record could be served a cached 200 where a fresh request would correctly 404, or vice versa; every cache key now folds in RecordCacheService::queryFingerprint(), which also reads the JSON body (see the BC note below on GETs with Content-Type: application/json). The search/filter/where cache-admission guard used Request::has(['search','filter','where']), which for an array of keys is ALL-of, not ANY-of — the guard only ever rejected a request carrying all three params simultaneously, so the overwhelmingly common case of one dynamic param slipped through and got cached; it is now hasAny(). Finally, a table function's or global function's clearCacheTables was write-side only — nothing on the read side declared that the function's cached output depended on those tables, so a write to a table a function reads from never invalidated that function's cache; RecordCacheService::functionCacheDependencies() now turns the same declaration into a read-side dependency list threaded through every QueryCacheService::get()/put() call for table and global functions, resolving tenancy per-dependency-table exactly as the write side does. See tests/Feature/CacheInvalidationCorrectnessTest.php, tests/Feature/RecordScopedInvalidationTest.php, tests/Feature/CacheKeyQueryParamTest.php, and tests/Feature/CacheAdmissionRulesTest.php.
  • Custom functions could still be served stale cache in two narrower cases the fixes above didn't reach: RecordCacheService::generateTableFunctionCacheKey() and generateGlobalFunctionCacheKey() — unlike the three other key generators — keyed only off $request->query(), so a GET carrying its read-shaping parameters in a JSON body (Content-Type: application/json, empty query string) could collide two functionally different function calls onto one cache entry; both now also fold in queryFingerprint(). Separately, isCacheableGlobalRequest()'s skip_query_params check used $request->query->has(), which cannot see a param sent in the JSON body, while its own search/filter/where guard two lines above was already body-aware via hasAny() — the same class of bug this release fixes everywhere else, just missed on this one branch; it now uses $request->has(), which is body-aware. See the new regression test in tests/Feature/CacheAdmissionRulesTest.php proving two GETs to the same global function with different JSON bodies ({"month":"01"} vs {"month":"02"}) no longer share a cache entry.
  • A 48-bit truncated hash in the cache-dependency token was a silent-staleness risk: QueryCacheService::resolveDependencyToken() truncated its digest to substr(md5(...), 0, 12) (48 bits). A collision would return the dependency token to a previously-used value while an entry cached under it was still within TTL — the API would then silently serve data it had already been told was stale, with no error or log. It now uses the full 128-bit md5() digest; this adds ~20 bytes per cache key, well inside memcached's 250-byte key limit.

[0.4.87] - 2026-08-12 ​

Fixed ​

  • Record API cache stored PHP-serialized stdClass rows → every cached list/show request failed with a 500 "incomplete object stdClass": RecordService reads rows through a DB query builder (DB::table()), so ->get() returns stdClass objects, and the list, cursor-pagination, dynamic-query, and show cache-write paths stored them raw. Serializing cache stores (database, file, redis-php) persist with PHP serialize(); restoring those objects on read could fail ("incomplete object"), and the failed response was then itself cached until TTL expiry. All four cache-write sites now normalize the payload to JSON-safe arrays/scalars first (RecordService::cacheSafePayload(), a JSON round-trip) — the same shape the custom-function cache path already used. Cache reads return plain arrays, which the response layer already handles. See tests/Feature/RecordListCacheSerializationTest.php (list/show/cursor round-trips through a real serializing store, plus a structural assertion that no cached payload contains serialized objects).

[0.4.86] - 2026-08-12 ​

Added ​

  • Every OpenAPI operation now documents its auth/permission contract, derived live from config: each operation's description gains an **Authorization:** line (e.g. **Authorization:** Bearer token required — write auth (\isAuthWrite=true` in config/records/tables/invoices.php); public endpoints read Public — no authentication required) plus, when configured, Permission scope(s):(from the table'spermissions[action]map) andRoute middleware:(resolved with the same precedence asRecordRouteMiddleware: function middlewareoverride →middleware_mapdefault + per-table merge across*/group/action). A machine-readable x-sp-auth extension on every operation carries the same data (auth, mode (read/write/function), flag, flag_value, public, permissions, middleware, tenant, source) so tooling and AI agents can query it without parsing prose. Sources: table CRUD uses isAuthRead/isAuthWrite; global functions use isPublic(defaulttrue); table RPC functions use isPublic(defaultfalse). The securityarrays are unchanged. Seetests/Feature/OpenApiTest.php (it_documents_auth_permissions_and_tenant_on_table_operations, it_documents_middleware_from_the_middleware_map_merging_default_and_table_entries, it_documents_global_function_publicity_from_config`, ...).
  • OpenAPI schemas now mirror table config behavior: columnHiddens columns are omitted from response/Read schemas (they never appear in responses) but stay in the Write schema; columnWriteDisabled fields are marked readOnly: true with a "silent no-op" note; with record.id_type=integer the id is excluded from the Write schema (auto-increment) while id_type=uuid keeps it as an optional string/uuid (server-generated via Str::uuid() when omitted); the {id} path parameter's schema type follows id_type; list operations declare the lazy query parameter.
  • Main OpenAPI document now states configured behavior: pagination defaults (record.pagination.default_mode, cursor default column, composite cursors, skip_total_default), relationship nesting depth limit (record.max_depth), primary-key type (record.id_type), per-table rate-limit overrides (record.rate_limits), and a corrected Authentication section pointing at per-operation Authorization docs (the old "all endpoints require a token" text was false once public endpoints exist). The orphaned Audit tag (no audit operations exist) was removed.

Fixed ​

  • Bruno/Postman exports fired non-callable requests: ApiClientExportService::buildQueryParam() enabled every query parameter, so one-click sends included all per-column filter placeholders with empty values (?id=&name=&...) which the API rejects. Parameters without a real default are now exported but disabled — Postman "disabled": true, Bruno ~param: syntax — while parameters with defaults (page, per_page, order, lazy, ...) stay enabled with their default values. See tests/Feature/ExportPostmanCommandTest.php and tests/Feature/ExportBrunoCommandTest.php.