Protecting actions with attributes
Who this is for
Ada can browse product.list without an account. She should not be able to invent catalog items or place someone else’s order. You want those rules next to the method — not buried in copy-pasted mustAuthenticate() calls.
What you will learn
- Lock one action or a whole service
- Leave
login/registerpublic withexcept - Choose
#[Can](all permissions) vs#[CanAny](any one) - When a rule needs the order row itself (write a few lines of PHP)
Before you start
- JWT authentication — Ada has a Bearer token after
customer.login
How it fits together
flowchart TD
Req["POST product.create + Bearer"] --> Jwt[JwtAuthentication]
Jwt -->|no user| E401[401]
Jwt -->|user ok| Attr{"#[Authenticated] / #[Can]?"}
Attr -->|missing permission| E403[403]
Attr -->|ok| Body[createAction]Pionia runs attributes before validation and before your method body. Anonymous callers never reach table('products')->save().
The three attributes
| Attribute | Plain English |
|---|---|
#[Authenticated] | Must be logged in |
#[Can('product.manage')] | Logged in and has this permission |
#[Can(['a', 'b'])] | Needs every permission listed |
#[CanAny(['a', 'b'])] | Needs any one permission |
Put permission slugs in the JWT when Ada logs in (permissions / perms). That is what #[Can] compares against.
Scenario: public catalog, private create
use Pionia\Auth\Attributes\Authenticated;
class ProductService extends Service
{
// Anyone can browse
protected function listAction(Arrayable $data): ApiResponse
{
return response(0, 'OK', ['products' => table('products')->all()]);
}
// Only signed-in staff/customers with access
#[Authenticated]
protected function createAction(Arrayable $data): ApiResponse
{
// save product …
}
}Try create without a token → 401. With Ada’s token → success.
Scenario: whole CustomerService, login stays open
use Pionia\Auth\Attributes\Authenticated;
use Pionia\Auth\Attributes\Can;
#[Authenticated(except: ['login', 'register'])]
class CustomerService extends Service
{
protected function loginAction(Arrayable $data): ApiResponse { /* public */ }
protected function meAction(Arrayable $data): ApiResponse
{
return response(0, 'OK', ['user' => $this->auth()->user]);
}
#[Can('customer.admin')]
protected function deactivateAction(Arrayable $data): ApiResponse { /* … */ }
}except accepts the action name clients send (login) or loginAction.
Scenario: checkout permissions
use Pionia\Auth\Attributes\Can;
use Pionia\Auth\Attributes\CanAny;
#[Can('order.place')]
protected function placeAction(Arrayable $data): ApiResponse { /* … */ }
#[CanAny(['order.refund', 'admin'])]
protected function refundAction(Arrayable $data): ApiResponse { /* … */ }When attributes are not enough
“Only the customer who owns this order may cancel it” needs the row:
#[Authenticated]
protected function cancelAction(Arrayable $data): ApiResponse
{
$order = table('orders')->get($data->getInt('id'));
if ((int) $order->customer_id !== (int) $this->auth()->user->id) {
return response(403, 'You can only cancel your own orders');
}
// …
}Try it yourself
- Put
#[Authenticated]onproduct.createand confirm anonymous curls fail. - Wrap
CustomerServicewithexcept: ['login']and confirm login still works. - Issue a token without
permissions, then hit#[Can('product.manage')]— expect 403.
Common mistakes
- Protecting
loginby accident - Expecting
#[Can]to invent permissions not present on the user - Mixing
#[Can(['a','b'])](all) with#[CanAny](any)
What’s next
JWT authentication
Put permissions in the token.
Tutorial Step 9
Wire login into Pionia Shop.