api-platform / api-platform/core
Laravel: Disabling Pagination Causes 403 on Policy-Protected Collection Endpoints
- Dominant language
- PHP
- Stars
- 2.6k
- Forks
- 980
- Avg merge
- 2d 5h
- Merged PRs (30d)
- 48
Description
**API Platform version(s) affected**: 4.3.17 (`api-platform/laravel`)
**Description**
Any `GetCollection` operation that has a `Policy`/`security` expression (resolved via a Laravel Policy's `viewAny()` method, e.g. through the standard `Gate::getPolicyFor($model)` naming-convention resolution) unconditionally returns **403 Access Denied** when the request disables client-side pagination (`?pagination=false` or `?pagination=0`), regardless of authentication state or the policy's actual logic — even when `viewAny()` unconditionally returns `true`.
Root cause: `ApiPlatform\Laravel\Security\ResourceAccessChecker::isGranted()` special-cases the `Paginator` wrapper class when deciding what object to pass to `Gate::allows()`:
```php
public function isGranted(string $resourceClass, string $expression, array $extraVariables = []): bool
{
$object = $extraVariables['object'] ?? null;
return Gate::allows(
$expression,
($object instanceof Paginator || null === $object) ? $resourceClass : $object
);
}
```
When pagination is enabled, `ApiPlatform\Laravel\Eloquent\State\CollectionProvider::provide()` returns an `ApiPlatform\Laravel\Eloquent\Paginator` instance, so `$object instanceof Paginator` is `true`, and `Gate::allows($expression, $resourceClass)` is called with the resource **class string** — which correctly resolves to the model's Policy class and calls `viewAny()`.
When pagination is disabled, the same provider instead returns a plain `Illuminate\Database\Eloquent\Collection` (see `CollectionProvider::provide()`, the `false === $this->pagination->isEnabled(...)` branch: `return $query->get();`). This is **not** a `Paginator`, so `ResourceAccessChecker` passes the `Collection` object itself to `Gate::allows($expression, $collection)`. Laravel's Gate then tries to resolve a policy based on `get_class($collection)` (`Illuminate\Database\Eloquent\Collection`), finds no policy registered for that class, and — absent any other applicable ability/`Gate::before()` override — denies the request by default.
The result: disabling pagination on any policy-protected collection endpoint always 403s, independent of the actual authorization rule.
**How to reproduce**
Any Eloquent resource with a Policy providing `viewAny()`:
```php
class PaymentMethodPolicy
{
public function viewAny(?RemoteUser $user): bool
{
return true; // unconditionally public
}
}
```
```
GET /api/payment_methods
→ 200 OK (Gate::allows('viewAny', PaymentMethod::class) — resolves the policy correctly)
GET /api/payment_methods?pagination=false
→ 403 {"detail":"Access Denied."}
(reproduces identically authenticated or unauthenticated)
GET /api/payment_methods?pagination=0
→ 403 (same)
```
**Possible Solution**
`ResourceAccessChecker::isGranted()` should pass the resource class string whenever `$object` is a bare collection of resources (i.e., not a single instance of the resource itself) rather than only special-casing `Paginator`. For example, also treat `PartialPaginator` and plain `Illuminate\Support\Collection`/`Illuminate\Database\Eloquent\Collection` instances the same way `Paginator` is treated:
```php
$isCollectionLike = $object instanceof Paginator
|| $object instanceof PartialPaginator
|| $object instanceof \Illuminate\Support\Collection;
return Gate::allows(
$expression,
($isCollectionLike || null === $object) ? $resourceClass : $object
);
```
**Additional Context**
- `CollectionProvider`: `vendor/api-platform/laravel/Eloquent/State/CollectionProvider.php`
- `ResourceAccessChecker`: `vendor/api-platform/laravel/Security/ResourceAccessChecker.php`
- `AccessCheckerProvider` (throw site): `vendor/api-platform/laravel/State/AccessCheckerProvider.php`
- This was found while debugging a checkout page that couldn't load any payment methods; it was initially conflated with an unrelated `BooleanFilter` validation bug (see `docs/bug-reports/api-platform-boolean-filter-validation.md`) because both bugs happened to be triggered by parameters on the same request (`?enabled=true&pagination=false`) and both produce failure responses that look superficially similar during triage (422 vs 403) unless isolated parameter-by-parameter.
- Workaround applied in our app: stopped disabling client-side pagination on this endpoint (removed `pagination: false` from the frontend query) — the resource's default page size (20) is more than sufficient for a payment-methods list, so no functional loss.
Contributor guide
Assessment
This issue has not been assessed yet.