nvl / forms
Secure dynamic Laravel forms with localized content and submission workflows
Requires
- php: ^8.3
- ext-ctype: *
- ext-filter: *
- ext-mbstring: *
- laravel/framework: ^13.0
- nesbot/carbon: ^2.72 || ^3.0
- nvl/core: ^2.0
- nvl/filterable: ^2.0
- nvl/tenancy: ^2.0
- nvl/translatable: ^2.0
- ramsey/uuid: ^4.7
- spatie/laravel-data: ^4.23
- spatie/typescript-transformer: ^3.3
- symfony/http-foundation: ^7.0 || ^8.0
- symfony/http-kernel: ^7.0 || ^8.0
Requires (Dev)
- larastan/larastan: ^3.10
- laravel/pint: ^1.27
- mockery/mockery: ^1.6
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
Suggests
- nvl/activity: Can consume the package's after-commit events in an application-owned audit integration
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-26 07:35:36 UTC
README
For support, open an issue. For vulnerabilities, use private reporting. See Contributing.
See the installation and publishing guide for Composer setup, configuration, migration ownership, and agent skills.
Quick reference
| Item | Value |
|---|---|
| Installed through | composer require nvl/forms:^2.0 |
| Module identifier | nvl/forms |
| PHP namespace | Nvl\Forms |
| Service provider | Nvl\Forms\Providers\FormsServiceProvider |
| Configuration | config/forms.php |
Purpose
nvl/forms is a headless form-definition and submission engine for Laravel 13 on PHP 8.4+. It owns secure form definitions, localized nested content, public rendering contracts, submissions, stored entries, analytics, and privacy operations. It does not ship an admin UI, frontend scaffold, mail provider, application-specific form types, or a required audit system.
Forms depends on nvl/core, nvl/filterable, nvl/tenancy, and nvl/translatable. nvl/activity is an optional event-driven integration.
Requirements and installation
composer require nvl/forms:^2.0 php artisan migrate
Laravel discovers Nvl\Forms\Providers\FormsServiceProvider. Clean-install migrations run by default. For an application with existing form tables, set forms.migrations.enabled to false, run the doctor, and follow UPGRADING.md before enabling migrations.
Optional publish tags:
php artisan vendor:publish --tag=forms-config php artisan vendor:publish --tag=forms-migrations php artisan vendor:publish --tag=forms-translations php artisan vendor:publish --tag=forms-skills
Choose exactly one migration owner. For automatic vendor loading, leave
forms.migrations.enabled=true and do not publish forms-migrations. For
host-owned migrations, publish forms-migrations, set
forms.migrations.enabled=false before the first migration, and maintain the
copied files as application migrations. Never run both sources; Laravel
retimestamps published migrations.
The skill is published as .agents/skills/nvl-forms.
First working form
Create form definitions through CreateFormAction and MutateFormPayload; do not write form or translation tables directly:
use Nvl\Forms\Actions\Form\CreateFormAction; use Nvl\Forms\Data\Mutations\MutateFormPayload; use Nvl\Forms\Enums\FormStatus; use Nvl\Forms\Enums\FormType; use Nvl\Forms\Enums\Resolvement; $form = app(CreateFormAction::class)->execute(new MutateFormPayload( handle: 'contact', translations: [ 'en' => [ 'name' => 'Contact us', 'submitButtonLabel' => 'Send', 'content' => [ 'sections' => [ ['fields' => [['name' => 'email', 'type' => 'email']]], ], ], ], ], status: FormStatus::ACTIVE, resolvement: Resolvement::ENTRIES, type: FormType::IFRAME, ));
The DTO validates locale maps and nested content. Form identity, status, availability, security settings, origins, and options remain locale-neutral. Localized names, descriptions, labels, success copy, sections, fields, options, validation messages, and provider extension copy live in forms_i18n.
Mutate safely
Use these Actions as the public write boundary:
CreateFormAction,UpdateFormAction,DuplicateFormAction,DeleteFormActionCreateFormEntryAction,MarkFormEntryAsSpamAction,MarkFormEntryAsLegitimateActionRedactFormEntryAction,AnonymizeFormEntryAction,DeleteFormEntryAction
Updates require MutateFormPayload::expectedRevision. A stale revision is rejected instead of overwriting a concurrent edit. Translation mode defaults to patch; use replace only when omitted locales should be deleted.
After a transaction commits, Forms dispatches the sanitized FormChangedEvent and FormEntryChangedEvent. The entry event carries the entry identifier rather than serializing submission PII. Subscribe to those events for notifications, activity capture, indexing, or other application behavior.
Extend definitions and rendering
- Register custom submission behavior with
FormHandlerRegistryandCustomFormHandler. - Register supplemental render data with
FormRenderDataRegistryandFormRenderDataProvider. - Register public error mappings with
FormErrorMapperRegistryandFormErrorMapper. - Register entry callbacks with
EntryCallbackRegistry.
Registries reject duplicate keys and invalid capabilities. Providers and handlers are container-resolved; the package never imports an application model or module.
Entry callbacks run after the outermost database transaction commits, including any transaction opened by the consuming application. A rollback discards pending callbacks. Each callback is isolated and reported independently: a failed integration does not make a persisted submission appear to have failed and does not stop later callbacks.
Forms registers forms.forms with TranslationResourceRegistry. Central gathering and synchronized translation edits therefore use the same field whitelist, authorization, and optimistic concurrency rules as other localized packages.
Public submission security
Public submission flows through HandlePublicFormSubmissionAction and SubmitFormPayload. The action composes availability, origin, token, rate-limit, honeypot, spam, payload, idempotency, persistence, callback, and response behavior.
Before enabling public routes, configure:
- explicit allowed origins and CORS behavior per form;
- CSRF or short-lived signed public-token strategy;
submission.max_payload_bytes,max_depth, andmax_items;- rate limits and block windows;
- honeypot names, spam weights, and thresholds;
- a stable idempotency key for retryable clients;
- retention and privacy policy appropriate to the collected data.
Minimum-submission timing uses trusted token issue time. A client-supplied timestamp is not trusted. Reusing an idempotency key with the same payload returns the original result; reusing it with a different payload is rejected.
Submission origin is request-derived. submittedFrom is not part of SubmitFormPayload, so a client cannot replace the trusted Origin, Referer, or request-host context with a payload value.
When allowMultipleRegistrations is false, Forms stores a SHA-256 registration fingerprint derived from the normalized email address or, when email is absent, the active session identifier. A submission without either identity is rejected. Database uniqueness prevents concurrent duplicates without storing the source identity in the fingerprint column.
Entry submissions persist idempotency state on the entry. Custom handlers use a separate durable receipt: completed retries replay the result, changed payloads conflict, and processing or failed attempts are not automatically re-executed because downstream side effects may already have occurred. Custom handlers should still make their own external operations idempotent.
Bind custom implementations of:
FormRateLimiterFormSpamDetectorFormEntryPrivacyPolicyFormEntryDeletionPolicy
The supplied privacy and deletion policies are permissive building blocks, not substitutes for application policy.
Both entry and custom submissions resolve honeypot checks, scores, and blocking/flagging decisions through the configured FormSpamDetector. Implementations need only the existing interface; built-in diagnostic flags are included when the default detector is used. Token issuance and validation require a nonempty application key. Malformed or empty base64: keys fail closed, and the doctor checks the same signing readiness rule.
CORS and iframe embedding
FormType::IFRAME is a presentation mode, not an authentication signal. Forms does not trust Sec-Fetch-Dest or custom iframe headers as proof of embedding. Restricted forms authorize the normalized request origin, and iframe responses expose a CSP frame-ancestors value for application middleware to apply.
Form and allowed-origin corsSettings use the typed FormCorsSettings contract:
'corsSettings' => [ 'policy' => 'custom', 'allowCredentials' => true, 'allowWildcards' => false, 'maxAge' => 600, 'allowedMethods' => ['GET', 'POST', 'OPTIONS'], 'allowedHeaders' => [ 'Content-Type', 'X-CSRF-TOKEN', 'X-Forms-Public-Token', 'Idempotency-Key', ], ],
Unknown keys, unsupported methods, unsafe header names, and out-of-range preflight cache values are rejected. Real OPTIONS requests pass through the same availability and origin policy as render, schema, and submit requests. Allowed-origin settings override form defaults for the matching origin.
Routes
Both route surfaces are disabled by default:
'routes' => [ 'prefix' => 'api/v1', 'middleware' => ['api'], 'management' => [ 'enabled' => false, 'middleware' => ['auth'], ], 'public' => [ 'enabled' => false, 'middleware' => ['throttle:forms-public'], ], ], 'authorization' => [ 'gate' => null, ],
Management routes use names beginning with nvl.forms.management.. Public render, schema, preflight, and submit routes use nvl.forms.public. and accept either a UUID or form handle. The lang query parameter selects a supported content locale. Availability, locale, origin, CORS, and throttling middleware apply consistently across the public surface.
Management authorization runs in route middleware before request DTO validation and is repeated at the controller boundary. The policy fails closed until forms.authorization.gate names a registered gate. The package does not assume an application middleware alias, frontend path, view directory, or user model.
Public render and schema contracts
Render responses expose PublicFormRenderPayload: localized content and copy, status, type, locale, public restrictions, and display options. Administrative counters, usage timestamps, security secrets, and storage details are not part of the render contract. Provider translations appear only under extension_translations.
Schema responses expose PublicFormSchemaPayload. Every rule is converted to a stable string representation; PHP validation-rule objects are never serialized. The built-in schema describes the generic submission envelope and payload bounds. Application-specific fields inside submissionData remain the custom handler or consuming renderer's semantic validation responsibility.
Entry privacy and operations
ExportFormEntriesAction exports only the selected authorized entry set. RedactFormEntryAction removes configured sensitive fields, AnonymizeFormEntryAction removes identifying values while preserving permitted aggregate data, and DeleteFormEntryAction delegates the final decision to FormEntryDeletionPolicy.
Exports use a unique file path for each invocation and fail if storage rejects the write. Moderation, security flags, and deletion reload and lock stored entry state before applying changes. Deletion policies inspect that current state; a stale model cannot bypass a legal hold or repeat a counter decrement.
Queue large exports and retention jobs in the consuming application. Do not place complete submission payloads in logs or events sent to untrusted listeners.
Database and identifiers
Package-owned rows use UUID primary keys. The schema separates forms, form translations, entries, custom-handler submission receipts, analytics, allowed origins, and rate-limit state. Lookup, status, availability, form/locale, idempotency, registration-fingerprint, and security query paths are indexed. Spam score is stored as a numeric zero-to-one-hundred value.
Set forms.migrations.enabled=false only for controlled adoption. A pre-existing table is not evidence that its columns, key types, indexes, or constraints match v1.
Commands
Tenant ownership
A Form is the canonical tenant root for definitions, translations, entries, submission receipts, origins, throttles, and analytics. Public site/token resolution establishes the tenant before lookup; callback jobs carry the captured tenant envelope and never infer ownership from request payloads.
php artisan nvl:forms:doctor php artisan nvl:forms:doctor --strict --format=json
The doctor does not mutate state. It checks required tables, columns, numeric score storage, indexes, foreign keys, privacy/rate/spam bindings, application-key readiness, public throttling, management authentication, and the configured gate's registration.
Generated TypeScript
Form DTO and enum sources register automatically with Core's Data provider under Nvl.Forms.*:
php artisan nvl:data:types:generate php artisan nvl:data:types:check
Use generated display DTOs for clients. Storage columns and internal security state are not a stable frontend contract.
Failure behavior
Validation failures return safe mapped errors at HTTP boundaries. Origin, token, rate-limit, spam, availability, repeat-registration, stale-revision, and idempotency conflicts are distinct failures. Database mutations are transactional, and events that imply durable state are dispatched after commit. External listeners must be idempotent.
Verification
The runnable examples above are mirrored by package tests. Before release, run the package Pest suite, Pint, PHPStan at maximum strictness, dependency analysis, generated-type checks, and package distribution validation.
See UPGRADING.md, SECURITY.md, CONTRIBUTING.md, and CHANGELOG.md.
License
Released under the MIT License.