Skip to content

Scope Laravel’s exists Rule to the Logged-In User

October 7, 2026 • 4 min read

Most online stores let customers save their addresses so they don’t have to type them in at every checkout. When a customer switches to a different saved address, the shipping fee has to be worked out again, since delivering to Oriental Mindoro doesn’t cost the same as delivering within Metro Manila. So the page sends the id of the address they picked:

PUT /cart/shipping-address
{"address_id": 57}

On the server, that id is usually validated with the exists rule:

'address_id' => ['required', 'integer', 'exists:addresses,id'],

It makes sure an address with that id exists. What it doesn’t check is whose address it is. The page only lists the customer’s own addresses, but the request takes any id. Changing 57 to 1 and sending it again returns this:

{
    "data": {
        "id": 5,
        "shipping_address": {
            "name": "Juan dela Cruz",
            "line1": "123 Rizal St",
            "city": "Calapan City",
            "province": "Oriental Mindoro",
            "postcode": "5200"
        },
        "shipping_fee": 165
    }
}

That’s another customer’s name and home address, now on their cart, before they’ve paid for anything.

The first fix most people reach for is a policy check, and it still gives something away. Let’s look at what each version returns, and then the change to the rule that fixes it.

Without an ownership check

If the controller saves whatever passed validation, nothing stops the customer from going through every id. When I sent the request for every id from 1 to 500, ids 1 to 412 all came back with an address: two of them the customer’s, 410 belonging to other customers. That’s every saved address in the store, one request each.

Adding a policy check

A policy that denies with denyAsNotFound() makes someone else’s address look the same as one that doesn’t exist. It’s what fixes this when the id is in the URL, as in Hide Private Records with denyAsNotFound() in Laravel:

declare(strict_types=1);

namespace App\Policies;

use App\Models\Address;
use App\Models\User;
use Illuminate\Auth\Access\Response;

final class AddressPolicy
{
    public function view(User $user, Address $address): Response
    {
        return $address->user()->is($user)
            ? Response::allow()
            : Response::denyAsNotFound();
    }
}

Then check it in the controller before the cart is updated:

Gate::authorize('view', Address::find($request->validated('address_id')));

Here’s the same loop with the policy in place:

ResponseRequestsIds
200257 and 58, the customer’s own
404410every other id from 1 to 412
42288413 to 500, which don’t exist

Other people’s addresses stay hidden now, but the customer can still tell which ids exist. The Form Request validates before the controller runs, so a missing id fails the exists rule with a 422 and the policy never sees it. Every 404 is a real address that belongs to someone else, and there are 410 of them.

Moving the check into the Form Request’s authorize() seems like it should help, since that runs before rules():

use Illuminate\Auth\Access\Response;

public function authorize(): Response
{
    return Gate::inspect('view', Address::find($this->input('address_id')));
}

But the two still don’t match: address 1 came back 404 and address 413 came back 403. For 413, find() returns null. Laravel has no policy to call for null, so the gate falls back to its default 403.

Scoping the exists rule

The rule should check what the checkout page shows, which is the customer’s own addresses:

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\Address;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

final class UpdateShippingAddressRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'address_id' => [
                'required',
                'integer',
                Rule::exists(Address::class, 'id')->where('user_id', $this->user()->id),
            ],
        ];
    }
}

Now only 57 and 58 get through. The other 498 ids fail validation whether they exist or not, and address 1 and address 413 get exactly the same response, byte for byte:

{
    "message": "The selected address id is invalid.",
    "errors": {
        "address_id": [
            "The selected address id is invalid."
        ]
    }
}

So whenever an id comes in through the request body, scope the exists rule to the records that user is allowed to pick. A policy on its own runs too late to hide which ids exist.

One thing to keep in mind: “this address belongs to this user” is now written twice, once in the policy and once in the rule. If you later let business customers use their company’s addresses, update both, or the checkout will reject addresses the policy allows.