feat(form-builder): standardised error envelope for public form API (D6)

S2c D6. Seven concrete exceptions over a shared PublicFormApiException
base + a single renderer in bootstrap/app.php produce the contract:

  { "message": "...", "code": "...", "errors"?: {...} }

Codes: SCHEMA_NOT_FOUND (404), TOKEN_EXPIRED (410), TOKEN_REVOKED (410),
SCHEMA_UNPUBLISHED (410), SUBMISSION_ALREADY_SUBMITTED (409),
RATE_LIMITED (429 with Retry-After header), VALIDATION_FAILED (422
with per-field errors).

Used by PublicFormController (resolve) and PublicFormSubmissionController
(load/submit lifecycle). Every public-form endpoint now emits the same
envelope regardless of which branch failed; the renderer only fires on
PublicFormApiException so the authenticated API still uses its default
Laravel shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 22:55:44 +02:00
parent a3f35e533f
commit 53fe4d25a7
9 changed files with 152 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
/**
* 422 for per-field validation failures raised by FormValueService when
* enforcing form_fields.validation_rules. FormRequests still throw
* Laravel's ValidationException directly; this class is for failures
* detected below the request layer.
*/
final class FieldValidationException extends PublicFormApiException
{
/**
* @param array<string, array<int, string>> $fieldErrors
*/
public function __construct(array $fieldErrors, string $message = 'The submitted values failed validation.')
{
parent::__construct('VALIDATION_FAILED', 422, $message);
$this->fieldErrors = $fieldErrors;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
use RuntimeException;
/**
* Base for every public form-builder API error. Renders as the S2c D6
* standardised envelope:
*
* { "message": "...", "code": "...", "errors"?: {...} }
*
* Concrete subclasses are tiny they only set the machine code, HTTP
* status, and the user-facing message.
*/
abstract class PublicFormApiException extends RuntimeException
{
/** @var array<string, array<int, string>>|null */
public ?array $fieldErrors = null;
/** @var array<string, string> */
public array $headers = [];
public function __construct(
public readonly string $publicCode,
public readonly int $status,
string $message,
) {
parent::__construct($message);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
final class RateLimitedException extends PublicFormApiException
{
public function __construct(int $retryAfterSeconds)
{
parent::__construct('RATE_LIMITED', 429, 'Too many submissions. Try again later.');
$this->headers['Retry-After'] = (string) max($retryAfterSeconds, 0);
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
final class SchemaNotFoundException extends PublicFormApiException
{
public function __construct()
{
parent::__construct('SCHEMA_NOT_FOUND', 404, 'Form not found.');
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
final class SchemaUnpublishedException extends PublicFormApiException
{
public function __construct()
{
parent::__construct('SCHEMA_UNPUBLISHED', 410, 'Form is not currently accepting submissions.');
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
final class SubmissionAlreadySubmittedException extends PublicFormApiException
{
public function __construct()
{
parent::__construct(
'SUBMISSION_ALREADY_SUBMITTED',
409,
'This submission has already been submitted and can no longer be edited.',
);
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
final class TokenExpiredException extends PublicFormApiException
{
public function __construct()
{
parent::__construct('TOKEN_EXPIRED', 410, 'This form link has expired.');
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\FormBuilder;
final class TokenRevokedException extends PublicFormApiException
{
public function __construct()
{
parent::__construct('TOKEN_REVOKED', 410, 'This form link has been revoked by the organiser.');
}
}

View File

@@ -39,6 +39,19 @@ return Application::configure(basePath: dirname(__DIR__))
]);
})
->withExceptions(function (Exceptions $exceptions): void {
// Public Form Builder standardised error envelope (S2c D6).
$exceptions->render(function (\App\Exceptions\FormBuilder\PublicFormApiException $e, Request $request) {
$body = [
'message' => $e->getMessage(),
'code' => $e->publicCode,
];
if ($e->fieldErrors !== null) {
$body['errors'] = $e->fieldErrors;
}
return response()->json($body, $e->status, $e->headers);
});
// Database connection / query errors → 503
$exceptions->render(function (QueryException|PDOException $e, Request $request) {
if ($request->expectsJson() || $request->is('api/*')) {