atoolo/form-bundle
Provides an HTTP interface for displaying and processing forms using JSON Forms in Symfony applications.
The most important thing to internalise before writing a line of code: atoolo/form-bundle is a Symfony bundle, not a Laravel package. It has no service provider, no facade, no config/form-bundle.php, and no Form facade to reach for. What it actually gives you is an HTTP interface that serves JSON Forms definitions (json-schema + uischema) and accepts submissions for processing.
So for a Laravel team, the realistic day-one decision is architectural, not composer require:
@jsonforms/react / @jsonforms/vue directly with your own Laravel endpoints. This is the fallback if you only wanted the JSON Forms idea, not the bundle.Assuming option 1:
# docker-compose.yml (sidecar)
forms:
image: your-registry/atoolo-form-service:1.1.0 # pin the exact tag
environment:
APP_ENV: prod
APP_SECRET: ${FORMS_APP_SECRET}
networks: [internal]
expose: ["80"]
Then wire it into Laravel:
// config/services.php
'forms' => [
'base_url' => env('FORMS_BASE_URL', 'http://forms'),
'token' => env('FORMS_SERVICE_TOKEN'),
'timeout' => env('FORMS_TIMEOUT', 3),
],
// app/Services/Forms/FormClient.php
namespace App\Services\Forms;
use Illuminate\Support\Facades\Http;
class FormClient
{
public function definition(string $formId, string $locale = 'en'): array
{
return Http::baseUrl(config('services.forms.base_url'))
->withToken(config('services.forms.token'))
->timeout(config('services.forms.timeout'))
->acceptJson()
->get("/forms/{$formId}", ['locale' => $locale])
->throw()
->json();
}
public function submit(string $formId, array $data): array
{
return Http::baseUrl(config('services.forms.base_url'))
->withToken(config('services.forms.token'))
->timeout(config('services.forms.timeout'))
->acceptJson()
->post("/forms/{$formId}", ['data' => $data])
->json();
}
}
Where to look first: the official docs at sitepark.github.io/atoolo-docs/develop/bundles/form/, then — because route names and payload shapes are not in the README — run bin/console debug:router inside the sidecar container to enumerate the actual endpoints. Treat that output as your API contract and commit it to the repo.
First use case: pick exactly one simple form (a contact/support form or an internal settings panel), fetch the definition through Laravel, render it with @jsonforms/react, and POST the result back through the same proxy. Do not start with multi-step or conditional forms.
1. Laravel as a thin, caching proxy. Never let the browser talk to the Symfony service directly. Route through Laravel so you can enforce auth, rate limiting, correlation IDs and caching in one place.
// routes/api.php
Route::middleware(['auth:sanctum', 'throttle:60,1'])->group(function () {
Route::get('/forms/{form}', [FormController::class, 'show']);
Route::post('/forms/{form}', [FormController::class, 'submit']);
});
2. Cache the definition aggressively. Schemas change on deploy, not per request. Key by form ID + locale + a version marker, and bust on release.
public function definition(string $formId, string $locale): array
{
$key = "forms:def:{$formId}:{$locale}:" . config('app.release');
return Cache::remember($key, now()->addHours(12), fn () =>
$this->client->definition($formId, $locale)
);
}
If you run Redis, use tags so a form-editor UI can invalidate a single form without nuking everything.
3. Let the Symfony service be the single source of truth for validation. It is tempting to re-derive Laravel rules from the JSON Schema. Don't, beyond cheap UX-level checks. JSON Schema constructs like oneOf, anyOf, $ref, if/then/else, and format have no faithful Laravel Validator equivalent, and the two will drift the moment someone edits a schema. Render the client-side errors from JSON Forms (AJV) and trust the server response for the authoritative verdict.
4. Adapter for the error shape. JSON Forms/AJV error payloads won't match Laravel's {message, errors: {field: []}} convention. Write one adapter, once:
protected function toValidationException(array $payload): ValidationException
{
// Inspect the actual 422 body from your service and map here.
// Typical shape: { errors: [ { instancePath: '/email', message: '...' } ] }
$errors = collect($payload['errors'] ?? [])
->groupBy(fn ($e) => ltrim($e['instancePath'] ?? '', '/'))
->map(fn ($group) => $group->pluck('message')->all())
->all();
return ValidationException::withMessages($errors);
}
5. Persist the raw submission as JSON, project later. A form_submissions table with form_id, schema_version, payload (json column), status, submitted_by gives you an audit trail and lets you re-project into typed tables as the schema evolves. Store the schema version returned by the definition endpoint — you'll need it to interpret old rows.
6. Keep side effects off the request path. The submit call is a network hop; don't stack emails, PDFs and third-party calls behind it.
$result = $this->forms->submit($formId, $validated);
$submission = FormSubmission::create([...]);
ProcessFormSubmission::dispatch($submission)->afterResponse();
7. Feature-flag / A-B variants. Because forms are data, shipping a variant is a config change, not a deploy. Resolve the form ID per user cohort, cache each definition separately, and log the variant ID alongside submissions so analytics can attribute conversions.
8. Frontend integration. Use @jsonforms/react or @jsonforms/vue with the definition your proxy returns. Custom widgets (date pickers, rich text, file upload) go in as JSON Forms renderers on the JS side — that's the real extension point for UI, and it means the Symfony side stays generic.
9. Test with Http::fake. Your whole test suite should run without the sidecar:
Http::fake([
'forms/forms/contact*' => Http::response(['schema' => [...], 'uischema' => [...]]),
]);
Add one separate contract test (nightly, against a running sidecar) that asserts the real endpoints still match your assumptions. That's the guard against a service upgrade silently breaking you.
.env conventions. Everything — auth, CSRF, session, tenancy — is yours to build in the proxy layer. Budget for that; it's not a "drop-in."1.0.0-beta.2 → 1.1.0), and a last-release date that looks like a typo. Use an exact image digest and copy the JSON schemas into your repo as fixtures. Assume you may have to fork or replace it.FormDefinitionRepository, FormSubmissionGateway) so swapping to native @jsonforms + Laravel validation later touches one binding, not forty call sites.->timeout(3)->retry(2, 150). Form definitions are safe to retry (idempotent GETs, cached anyway). Submissions are not — send an Idempotency-Key derived from a client-generated UUID and dedupe server-side in Laravel, or a retried double-click creates two tickets.schema_version and treat old submissions as read-only snapshots.ConnectionException / 5xx produce a friendly "form temporarily unavailable" rather than a 500 to the user.422 vs 400 vs 200-with-errors. Confirm empirically what the service returns on invalid input; don't assume HTTP semantics. ->throw() will mask the body, so check $response->status() before throwing when you need the error detail.application/json or application/merge-patch+json; whether there's a CSRF/cookie requirement that leaks through. Run debug:router and debug:config once and write the answers into your own README — you will forget.FormDefinitionRepository implementation (swappability). Everything else is a fork.How can I help you explore Laravel packages today?