refactor(form-builder): restore type-hinted route model binding for failures controller (WS-6)
Replace the manual `$request->route('formSubmissionActionFailure')` workaround
with type-hinted parameters. Implicit route model binding now resolves
FormSubmissionActionFailure correctly on both the platform admin route
(/admin/form-failures/{id}) and the org-scoped route
(/organisations/{organisation}/form-failures/{id}).
Root cause:
On the nested org-scoped route, Laravel's implicit binding triggers its
scoped-binding code path: for the second URL segment, it tries to resolve
the failure as a relation of the route's parent ({organisation}) by calling
`$organisation->formSubmissionActionFailures()`. Organisation has no such
relation (failures live under FormSubmission, not Organisation directly),
so the lookup silently fell through and the controller received a raw
string. PHP then raised a TypeError on the type-hinted parameter.
A second issue compounded it: with the controller method declaring
`(FormSubmissionActionFailure $formSubmissionActionFailure, ?Organisation $organisation)`
the parameter order did NOT match the URL parameter order
(/{organisation}/.../{formSubmissionActionFailure}), so Laravel's
resolveMethodDependencies — which falls back to positional binding when
parameter counts diverge — bound them to the wrong slots.
Fix:
- Register an explicit `Route::bind('formSubmissionActionFailure', ...)`
in AppServiceProvider that loads the model `withoutGlobalScopes()` and
throws ModelNotFoundException on miss. This sidesteps the scoped-binding
parent-relation lookup entirely.
- Add `->withoutScopedBindings()` to all four org-scoped routes (show,
retry, resolve, dismiss) as a belt-and-braces guarantee that Laravel
never enters the scoped-binding path for these nested routes.
- Reorder controller method signatures to put `?Organisation $organisation`
FIRST, matching URL parameter order so positional binding lands the
ULID strings on the correct method parameters.
- Drop the now-unused private `resolveFailure()` helper.
- Tenant scoping continues to be enforced by FormSubmissionActionFailurePolicy
via the failure.submission.organisation_id FK chain (RFC V3); cross-
tenant access still translates denied → 404, never 403.
Tests: all 9 controller tests pass (cross-tenant 404 contract verified for
view, dismiss, and resolve).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,17 +55,23 @@ final class FormSubmissionActionFailureController extends Controller
|
||||
return FormSubmissionActionFailureResource::collection($failures);
|
||||
}
|
||||
|
||||
public function show(\Illuminate\Http\Request $request): FormSubmissionActionFailureResource
|
||||
public function show(?Organisation $organisation, FormSubmissionActionFailure $formSubmissionActionFailure): FormSubmissionActionFailureResource
|
||||
{
|
||||
$failure = $this->resolveFailure((string) $request->route('formSubmissionActionFailure'));
|
||||
$this->authorizeOrNotFound('view', $failure);
|
||||
// $organisation is bound only on the org-scoped route; null on the
|
||||
// platform admin route. We don't use it directly — the policy
|
||||
// enforces tenant scope via failure.submission.organisation_id (RFC V3).
|
||||
// Declaration order matches URL parameter order so Laravel's
|
||||
// resolveMethodDependencies binds positionally as expected.
|
||||
unset($organisation);
|
||||
$this->authorizeOrNotFound('view', $formSubmissionActionFailure);
|
||||
|
||||
return new FormSubmissionActionFailureResource($failure);
|
||||
return new FormSubmissionActionFailureResource($formSubmissionActionFailure);
|
||||
}
|
||||
|
||||
public function retry(\Illuminate\Http\Request $request, FormBindingApplicator $applicator): FormSubmissionActionFailureResource|JsonResponse
|
||||
public function retry(?Organisation $organisation, FormSubmissionActionFailure $formSubmissionActionFailure, FormBindingApplicator $applicator): FormSubmissionActionFailureResource|JsonResponse
|
||||
{
|
||||
$failure = $this->resolveFailure((string) $request->route('formSubmissionActionFailure'));
|
||||
unset($organisation);
|
||||
$failure = $formSubmissionActionFailure;
|
||||
$this->authorizeOrNotFound('retry', $failure);
|
||||
|
||||
if (! $failure->canBeRetried()) {
|
||||
@@ -119,9 +125,12 @@ final class FormSubmissionActionFailureController extends Controller
|
||||
}
|
||||
|
||||
public function resolve(
|
||||
?Organisation $organisation,
|
||||
FormSubmissionActionFailure $formSubmissionActionFailure,
|
||||
ResolveFailureRequest $request,
|
||||
): FormSubmissionActionFailureResource|JsonResponse {
|
||||
$failure = $this->resolveFailure((string) $request->route('formSubmissionActionFailure'));
|
||||
unset($organisation);
|
||||
$failure = $formSubmissionActionFailure;
|
||||
$this->authorizeOrNotFound('resolve', $failure);
|
||||
|
||||
if ($failure->resolved_at !== null) {
|
||||
@@ -145,9 +154,12 @@ final class FormSubmissionActionFailureController extends Controller
|
||||
}
|
||||
|
||||
public function dismiss(
|
||||
?Organisation $organisation,
|
||||
FormSubmissionActionFailure $formSubmissionActionFailure,
|
||||
DismissFailureRequest $request,
|
||||
): FormSubmissionActionFailureResource|JsonResponse {
|
||||
$failure = $this->resolveFailure((string) $request->route('formSubmissionActionFailure'));
|
||||
unset($organisation);
|
||||
$failure = $formSubmissionActionFailure;
|
||||
$this->authorizeOrNotFound('dismiss', $failure);
|
||||
|
||||
if ($failure->resolved_at !== null) {
|
||||
@@ -171,24 +183,6 @@ final class FormSubmissionActionFailureController extends Controller
|
||||
return new FormSubmissionActionFailureResource($failure->refresh());
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual model resolution. Implicit binding doesn't pick up nested-
|
||||
* namespace ULID models reliably across nested route groups, so we
|
||||
* load explicitly without global scopes (cross-tenant access reaches
|
||||
* the policy, which then translates denied → 404 per RFC V3).
|
||||
*/
|
||||
private function resolveFailure(string $id): FormSubmissionActionFailure
|
||||
{
|
||||
$failure = FormSubmissionActionFailure::query()
|
||||
->withoutGlobalScopes()
|
||||
->find($id);
|
||||
if ($failure === null) {
|
||||
throw new ModelNotFoundException();
|
||||
}
|
||||
|
||||
return $failure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a denied policy to 404 (RFC V3) so cross-tenant access
|
||||
* does NOT confirm resource existence.
|
||||
@@ -196,7 +190,7 @@ final class FormSubmissionActionFailureController extends Controller
|
||||
private function authorizeOrNotFound(string $ability, FormSubmissionActionFailure $failure): void
|
||||
{
|
||||
if (Gate::denies($ability, $failure)) {
|
||||
throw new ModelNotFoundException();
|
||||
throw new ModelNotFoundException;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user