Output:
Chunk ID: 0c655c
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 4162
Output:
specs/02-API-ROUTES.md:1:# API and Routing Specification
specs/02-API-ROUTES.md:3:This document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.
specs/02-API-ROUTES.md:34:All admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).
specs/02-API-ROUTES.md:107:| GET | `/admin/pages` | Content pages list | `Admin\Pages\Index` |
specs/02-API-ROUTES.md:108:| GET | `/admin/pages/create` | Create content page | `Admin\Pages\Create` |
specs/02-API-ROUTES.md:109:| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\Pages\Edit` |
specs/02-API-ROUTES.md:128:| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\Developers\Index` |
specs/02-API-ROUTES.md:130:> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.
specs/02-API-ROUTES.md:160:| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\Pages\Show` |
specs/02-API-ROUTES.md:197:## 2. Storefront REST API
specs/02-API-ROUTES.md:422:    participant API as Storefront API
specs/02-API-ROUTES.md:425:    C->>API: POST /checkouts (cart_id, email)
specs/02-API-ROUTES.md:426:    API-->>C: 201 Checkout (status: started)
specs/02-API-ROUTES.md:428:    C->>API: PUT /checkouts/{id}/address
specs/02-API-ROUTES.md:429:    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)
specs/02-API-ROUTES.md:431:    C->>API: PUT /checkouts/{id}/shipping-method
specs/02-API-ROUTES.md:432:    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)
specs/02-API-ROUTES.md:435:        C->>API: POST /checkouts/{id}/apply-discount
specs/02-API-ROUTES.md:436:        API-->>C: 200 Checkout (totals recalculated)
specs/02-API-ROUTES.md:439:    C->>API: POST /checkouts/{id}/pay (payment_method + details)
specs/02-API-ROUTES.md:440:    API->>API: Reserve inventory
specs/02-API-ROUTES.md:441:    API->>MockPSP: charge(checkout, paymentMethodData)
specs/02-API-ROUTES.md:444:        MockPSP-->>API: PaymentResult (success)
specs/02-API-ROUTES.md:445:        API->>API: Create order (paid), commit inventory
specs/02-API-ROUTES.md:446:        API-->>C: 200 (order confirmation data)
specs/02-API-ROUTES.md:448:        MockPSP-->>API: PaymentResult (failed)
specs/02-API-ROUTES.md:449:        API->>API: Release reserved inventory
specs/02-API-ROUTES.md:450:        API-->>C: 422 (error: card_declined / insufficient_funds)
specs/02-API-ROUTES.md:452:        MockPSP-->>API: PaymentResult (pending)
specs/02-API-ROUTES.md:453:        API->>API: Create order (pending), inventory stays reserved
specs/02-API-ROUTES.md:454:        API-->>C: 200 (order confirmation + bank transfer instructions)
specs/02-API-ROUTES.md:850:Process payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.
specs/02-API-ROUTES.md:1206:## 3. Admin REST API
specs/02-API-ROUTES.md:1214:All endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.
specs/02-API-ROUTES.md:1703:  "content_type": "image/jpeg",
specs/02-API-ROUTES.md:1711:| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |
specs/02-API-ROUTES.md:1732:- `422` - Validation errors (invalid content type, file too large)
specs/02-API-ROUTES.md:2604:| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2605:| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2606:| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2607:| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2609:#### GET /api/admin/v1/stores/{storeId}/pages
specs/02-API-ROUTES.md:2611:List content pages.
specs/02-API-ROUTES.md:2648:#### POST /api/admin/v1/stores/{storeId}/pages
specs/02-API-ROUTES.md:2650:Create a content page.
specs/02-API-ROUTES.md:2678:#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}
specs/02-API-ROUTES.md:2680:Update a content page.
specs/02-API-ROUTES.md:2693:#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}
specs/02-API-ROUTES.md:2695:Delete a content page.
specs/02-API-ROUTES.md:2697:**Authentication:** Bearer token with `write-content` ability.
specs/02-API-ROUTES.md:2977:### App API Endpoints
specs/02-API-ROUTES.md:2983:| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |
specs/02-API-ROUTES.md:3027:#### API Storefront
specs/02-API-ROUTES.md:3036:#### API Admin
specs/02-API-ROUTES.md:3056:- **API (storefront):** Same as storefront web - hostname-based resolution.
specs/02-API-ROUTES.md:3057:- **API (admin):** Reads `storeId` from route parameter and verifies the authenticated user has membership.
specs/02-API-ROUTES.md:3081:| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |
specs/02-API-ROUTES.md:3082:| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |
specs/02-API-ROUTES.md:3169:## 9. API Versioning Strategy
specs/02-API-ROUTES.md:3171:- **Storefront API:** Versioned via URL path (`/api/storefront/v1/`). Breaking changes require a new version.
specs/02-API-ROUTES.md:3172:- **Admin API:** Versioned via URL path (`/api/admin/v1/`). Same strategy.
specs/02-API-ROUTES.md:3173:- **App API:** Versioned via URL path (`/api/apps/v1/`).
specs/02-API-ROUTES.md:3182:All API error responses follow a consistent JSON structure:
specs/01-DATABASE-SCHEMA.md:423:### pages
specs/01-DATABASE-SCHEMA.md:425:Static CMS pages (About Us, Contact, etc.).
specs/01-DATABASE-SCHEMA.md:433:| body_html | TEXT | Yes | NULL | - | Rich-text content |
specs/01-DATABASE-SCHEMA.md:441:- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
specs/01-DATABASE-SCHEMA.md:442:- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store
specs/01-DATABASE-SCHEMA.md:443:- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status
specs/01-DATABASE-SCHEMA.md:478:| resource_id | INTEGER | Yes | NULL | - | FK to pages/collections/products (polymorphic) |
specs/01-DATABASE-SCHEMA.md:1325:    stores ||--o{ pages : "has"
specs/01-DATABASE-SCHEMA.md:1345:    pages {
specs/01-DATABASE-SCHEMA.md:1500:- The `busy_timeout` must be set to `5000` (milliseconds) to handle write contention gracefully.
specs/01-DATABASE-SCHEMA.md:1529:        pages
specs/01-DATABASE-SCHEMA.md:1589:    stores --> pages
specs/01-DATABASE-SCHEMA.md:1645:- **Batch 3** (depends on Batch 2): `store_domains`, `store_users`, `store_settings`, `customers`, `themes`, `pages`, `navigation_menus`, `search_settings`, `shipping_zones`, `tax_settings`, `discounts`, `products`, `collections`, `app_installations`, `oauth_clients`
specs/01-DATABASE-SCHEMA.md:1675:| 3 - Themes, Storefront, Localization | themes, theme_files, theme_settings, pages, navigation_menus, navigation_items | 6 |
specs/03-ADMIN-UI.md:13:The admin layout is a persistent shell that wraps every admin page. It consists of a left sidebar, a top bar, and a main content area.
specs/03-ADMIN-UI.md:92:  Pages                          -> admin.pages.index
specs/03-ADMIN-UI.md:113:- **Desktop (lg and above):** Sidebar is always visible, fixed left, 256px wide. Main content has a left margin of 256px.
specs/03-ADMIN-UI.md:173:The main content area sits to the right of the sidebar and below the top bar. Every page renders within this area.
specs/03-ADMIN-UI.md:175:**Breadcrumbs:** Every page includes a Flux breadcrumbs component at the top of the content area. The first item is always "Home" linking to `/admin`. Subsequent items reflect the page hierarchy. Example for product edit: Home > Products > "Blue T-Shirt".
specs/03-ADMIN-UI.md:177:**Layout wrapper:** The content area has consistent padding (larger on desktop, smaller on mobile) and a max-width container centered horizontally.
specs/03-ADMIN-UI.md:1124:| Text content | `text` |
specs/03-ADMIN-UI.md:1468:- Type select: Storefront, Admin, API
specs/03-ADMIN-UI.md:1603:| `providerApiKey` | `string` | API key for tax provider |
specs/03-ADMIN-UI.md:1631:|  API key:  [*************]                                       |
specs/03-ADMIN-UI.md:1647:**Provider config:** Shown when mode = "provider". Provider select (Stripe Tax / None) and API key password input.
specs/03-ADMIN-UI.md:1807:**Route:** `GET /admin/pages`
specs/03-ADMIN-UI.md:1811:**Blade view:** `resources/views/livewire/admin/pages/index.blade.php`
specs/03-ADMIN-UI.md:1821:| `pages` | Paginated pages for the current store |
specs/03-ADMIN-UI.md:1832:**Route:** `GET /admin/pages/create` and `GET /admin/pages/{page}/edit`
specs/03-ADMIN-UI.md:1836:**Blade view:** `resources/views/livewire/admin/pages/form.blade.php`
specs/03-ADMIN-UI.md:1845:| `bodyHtml` | `string` | Page body content (HTML) |
specs/03-ADMIN-UI.md:2003:- Usage stats (API calls count, last call timestamp)
specs/03-ADMIN-UI.md:2021:| `tokens` | `Collection` | API tokens for the store |
specs/03-ADMIN-UI.md:2031:| `generateToken()` | Creates a new API token |
specs/03-ADMIN-UI.md:2044:| API TOKENS                                                        |
specs/03-ADMIN-UI.md:2045:| Manage personal access tokens for the Admin API.                 |
specs/03-ADMIN-UI.md:2074:**API Tokens section:**
specs/03-ADMIN-UI.md:2075:- Heading "API tokens" (size lg)
specs/03-ADMIN-UI.md:2087:| Heading | "Generate API token" |
specs/03-ADMIN-UI.md:2193:- Channel filter: Flux select with All, Storefront, API
specs/03-ADMIN-UI.md:2296:All list pages follow a consistent pattern:
specs/03-ADMIN-UI.md:2307:All form pages follow a consistent pattern:
specs/03-ADMIN-UI.md:2310:3. Left column: primary content fields
specs/03-ADMIN-UI.md:2402:| `App\Livewire\Admin\Layout\Sidebar` | `livewire.admin.layout.sidebar` | Layout (all pages) |
specs/03-ADMIN-UI.md:2403:| `App\Livewire\Admin\Layout\TopBar` | `livewire.admin.layout.top-bar` | Layout (all pages) |
specs/03-ADMIN-UI.md:2422:| `App\Livewire\Admin\Pages\Index` | `livewire.admin.pages.index` | `GET /admin/pages` |
specs/03-ADMIN-UI.md:2423:| `App\Livewire\Admin\Pages\Form` | `livewire.admin.pages.form` | `GET /admin/pages/create`, `GET /admin/pages/{page}/edit` |
specs/09-IMPLEMENTATION-ROADMAP.md:22:| Auth (API) | Sanctum tokens | Sanctum personal access tokens | For programmatic API access |
specs/09-IMPLEMENTATION-ROADMAP.md:25:| Payments | Stripe + PayPal | Mock PSP (in-process) | No external API calls. MockPaymentProvider handles credit card, PayPal, bank transfer with magic card numbers for testing. |
specs/09-IMPLEMENTATION-ROADMAP.md:176:Applied on: products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.
specs/09-IMPLEMENTATION-ROADMAP.md:358:4. `create_pages_table` - FK: `store_id`
specs/09-IMPLEMENTATION-ROADMAP.md:401:| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |
specs/09-IMPLEMENTATION-ROADMAP.md:409:Base layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.
specs/09-IMPLEMENTATION-ROADMAP.md:507:All mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.
specs/09-IMPLEMENTATION-ROADMAP.md:672:- No external API calls, no webhooks, no redirects
specs/09-IMPLEMENTATION-ROADMAP.md:714:| `create(Order $order, Payment $payment, int $amount, ?string $reason, bool $restock): Refund` | Validates amount does not exceed payment, calls provider refund API, updates financial status |
specs/09-IMPLEMENTATION-ROADMAP.md:845:| `Admin\Pages\Index` | Content pages list |
specs/09-IMPLEMENTATION-ROADMAP.md:852:| `Admin\Developers\Index` | API token management, webhook subscriptions |
specs/09-IMPLEMENTATION-ROADMAP.md:864:3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table
specs/09-IMPLEMENTATION-ROADMAP.md:957:1. Accessibility audit: skip links on all pages, ARIA labels on interactive elements, focus management in modals
specs/09-IMPLEMENTATION-ROADMAP.md:958:2. Responsive testing: all pages render correctly at sm/md/lg/xl breakpoints
specs/09-IMPLEMENTATION-ROADMAP.md:960:4. Error pages: styled 404 and 503 pages matching the storefront theme
specs/09-IMPLEMENTATION-ROADMAP.md:1194:| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1195:| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |
specs/09-IMPLEMENTATION-ROADMAP.md:1197:| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1301:| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1302:| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1303:| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1304:| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1305:| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1669:| creates a product via API | POST /api/admin/v1/stores/{storeId}/products with product data | 201 response, product in response | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1670:| updates a product via API | PUT /api/admin/v1/stores/{storeId}/products/{id} with updated data | 200 response, product updated | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1671:| deletes a draft product via API | DELETE /api/admin/v1/stores/{storeId}/products/{id} | 200 response | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1685:| creates a fulfillment via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/fulfillments | 201 response, fulfillment created | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1686:| creates a refund via API | POST /api/admin/v1/stores/{storeId}/orders/{id}/refunds | 201 response, refund created | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1772:| `Admin/Developers/Index` | API token and webhook management |
specs/09-IMPLEMENTATION-ROADMAP.md:1888:| `Auth/SanctumTokenTest` | API token feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1895:| `Cart/CartApiTest` | Cart API feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1921:| `Api/StorefrontCartApiTest` | Storefront cart API feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1922:| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1923:| `Api/AdminProductApiTest` | Admin product API feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1924:| `Api/AdminOrderApiTest` | Admin order API feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1952:| `routes/api.php` | Storefront API (prefix `/api/storefront/v1`), Admin API (prefix `/api/admin/v1`), Webhook receiver routes |
specs/09-IMPLEMENTATION-ROADMAP.md:1977:| `RefundService` | Refund creation, provider API, inventory restock |


sed -n '1206,1445p' specs/02-API-ROUTES.md


sed -n '2600,2705p' specs/02-API-ROUTES.md


sed -n '423,446p' specs/01-DATABASE-SCHEMA.md


sed -n '423,446p' specs/01-DATABASE-SCHEMA.md

### pages

Static CMS pages (About Us, Contact, etc.).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| title | TEXT | No | - | - | Page title |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
| body_html | TEXT | Yes | NULL | - | Rich-text content |
| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |
| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store
- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status

---


sed -n '1206,1445p' specs/02-API-ROUTES.md

## 3. Admin REST API

**Prefix:** `/api/admin/v1`
**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`
**Content-Type:** `application/json`

These endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.

All endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.

---

### 3.1 Platform Management (Super-Admin Only)

These endpoints are restricted to users with the `owner` (platform) role.

#### POST /api/admin/v1/platform/organizations

Create a new organization.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "name": "Acme Corp",
  "billing_email": "billing@acme.com"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | Max: 255 |
| `billing_email` | string | Yes | Valid email, max: 255 |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "name": "Acme Corp",
    "billing_email": "billing@acme.com",
    "created_at": "2026-02-11T10:00:00Z",
    "updated_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors

---

#### POST /api/admin/v1/platform/stores

Create a new store within an organization.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "organization_id": 1,
  "name": "Acme Store",
  "handle": "acme",
  "default_currency": "EUR",
  "default_locale": "en",
  "timezone": "Europe/Berlin"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `organization_id` | integer | Yes | Must exist |
| `name` | string | Yes | Max: 255 |
| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |
| `default_currency` | string | Yes | Valid ISO 4217 code |
| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |
| `timezone` | string | Yes | Valid IANA timezone |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "organization_id": 1,
    "name": "Acme Store",
    "handle": "acme",
    "status": "active",
    "default_currency": "EUR",
    "default_locale": "en",
    "timezone": "Europe/Berlin",
    "created_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (duplicate handle, invalid timezone, etc.)

---

#### POST /api/admin/v1/stores/{storeId}/invites

Invite a user to a store with a specific role.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "email": "staff@acme.com",
  "role": "staff"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `email` | string | Yes | Valid email, max: 255 |
| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |

**Success response:** `201 Created`

```json
{
  "data": {
    "email": "staff@acme.com",
    "role": "staff",
    "invited_at": "2026-02-11T10:00:00Z",
    "expires_at": "2026-02-18T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions (only `owner` and `admin` can invite)
- `409` - User already a member of this store
- `422` - Validation errors

---

#### GET /api/admin/v1/stores/{storeId}/me

Get the current user's membership details for a store.

- **Authentication:** Sanctum token
- **Rate limit:** `api.admin` (60/min per token)

**Success response:** `200 OK`

```json
{
  "data": {
    "user_id": 3,
    "store_id": 1,
    "role": "admin",
    "email": "admin@acme.com",
    "name": "Jane Admin",
    "permissions": [
      "read-products",
      "write-products",
      "read-orders",
      "write-orders",
      "read-settings",
      "write-settings",
      "read-customers"
    ]
  }
}
```

**Error responses:**
- `403` - Not a member of this store
- `404` - Store not found

---

### 3.2 Products

#### Admin Products Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/products

List products with filtering and pagination.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |
| `query` | string | No | Search by title, vendor, or SKU. |
| `collection_id` | integer | No | Filter by collection membership. |
| `page` | integer | No | Page number. Default: 1. |
| `per_page` | integer | No | Results per page. Default: 25, max: 100. |
| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |

**Success response:** `200 OK`

```json
{
  "data": [
    {
      "id": 10,
      "store_id": 1,
      "title": "Classic T-Shirt",
      "handle": "classic-t-shirt",
      "status": "active",
      "vendor": "Acme Apparel",
      "product_type": "Apparel",
      "tags": ["organic", "cotton"],
      "variants_count": 6,
      "total_inventory": 150,
      "published_at": "2026-02-10T08:00:00Z",
      "created_at": "2026-02-09T14:00:00Z",
      "updated_at": "2026-02-11T09:00:00Z",
      "featured_image": {
        "url": "https://cdn.example.com/products/tshirt.jpg",
        "alt_text": "Classic T-Shirt in Blue"
      }
    }
  ],


Output:
Chunk ID: 707c21
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1651
Output:
## 3. Admin REST API

**Prefix:** `/api/admin/v1`
**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`
**Content-Type:** `application/json`

These endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.

All endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.

---

### 3.1 Platform Management (Super-Admin Only)

These endpoints are restricted to users with the `owner` (platform) role.

#### POST /api/admin/v1/platform/organizations

Create a new organization.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "name": "Acme Corp",
  "billing_email": "billing@acme.com"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | Max: 255 |
| `billing_email` | string | Yes | Valid email, max: 255 |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "name": "Acme Corp",
    "billing_email": "billing@acme.com",
    "created_at": "2026-02-11T10:00:00Z",
    "updated_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors

---

#### POST /api/admin/v1/platform/stores

Create a new store within an organization.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "organization_id": 1,
  "name": "Acme Store",
  "handle": "acme",
  "default_currency": "EUR",
  "default_locale": "en",
  "timezone": "Europe/Berlin"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `organization_id` | integer | Yes | Must exist |
| `name` | string | Yes | Max: 255 |
| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |
| `default_currency` | string | Yes | Valid ISO 4217 code |
| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |
| `timezone` | string | Yes | Valid IANA timezone |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "organization_id": 1,
    "name": "Acme Store",
    "handle": "acme",
    "status": "active",
    "default_currency": "EUR",
    "default_locale": "en",
    "timezone": "Europe/Berlin",
    "created_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (duplicate handle, invalid timezone, etc.)

---

#### POST /api/admin/v1/stores/{storeId}/invites

Invite a user to a store with a specific role.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "email": "staff@acme.com",
  "role": "staff"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `email` | string | Yes | Valid email, max: 255 |
| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |

**Success response:** `201 Created`

```json
{
  "data": {
    "email": "staff@acme.com",
    "role": "staff",
    "invited_at": "2026-02-11T10:00:00Z",
    "expires_at": "2026-02-18T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions (only `owner` and `admin` can invite)
- `409` - User already a member of this store
- `422` - Validation errors

---

#### GET /api/admin/v1/stores/{storeId}/me

Get the current user's membership details for a store.

- **Authentication:** Sanctum token
- **Rate limit:** `api.admin` (60/min per token)

**Success response:** `200 OK`

```json
{
  "data": {
    "user_id": 3,
    "store_id": 1,
    "role": "admin",
    "email": "admin@acme.com",
    "name": "Jane Admin",
    "permissions": [
      "read-products",
      "write-products",
      "read-orders",
      "write-orders",
      "read-settings",
      "write-settings",
      "read-customers"
    ]
  }
}
```

**Error responses:**
- `403` - Not a member of this store
- `404` - Store not found

---

### 3.2 Products

#### Admin Products Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/products

List products with filtering and pagination.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |
| `query` | string | No | Search by title, vendor, or SKU. |
| `collection_id` | integer | No | Filter by collection membership. |
| `page` | integer | No | Page number. Default: 1. |
| `per_page` | integer | No | Results per page. Default: 25, max: 100. |
| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |

**Success response:** `200 OK`

```json
{
  "data": [
    {
      "id": 10,
      "store_id": 1,
      "title": "Classic T-Shirt",
      "handle": "classic-t-shirt",
      "status": "active",
      "vendor": "Acme Apparel",
      "product_type": "Apparel",
      "tags": ["organic", "cotton"],
      "variants_count": 6,
      "total_inventory": 150,
      "published_at": "2026-02-10T08:00:00Z",
      "created_at": "2026-02-09T14:00:00Z",
      "updated_at": "2026-02-11T09:00:00Z",
      "featured_image": {
        "url": "https://cdn.example.com/products/tshirt.jpg",
        "alt_text": "Classic T-Shirt in Blue"
      }
    }
  ],


sed -n '2600,2705p' specs/02-API-ROUTES.md

#### Admin Pages Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/pages

List content pages.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | Filter: `draft`, `published`. |
| `page` | integer | No | Default: 1. |
| `per_page` | integer | No | Default: 25, max: 100. |

**Success response:** `200 OK`

```json
{
  "data": [
    {
      "id": 1,
      "store_id": 1,
      "title": "About Us",
      "handle": "about-us",
      "status": "published",
      "published_at": "2026-02-01T10:00:00Z",
      "created_at": "2026-01-15T10:00:00Z",
      "updated_at": "2026-02-01T10:00:00Z"
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 25,
    "total": 3,
    "last_page": 1
  }
}
```

---

#### POST /api/admin/v1/stores/{storeId}/pages

Create a content page.

**Request body:**

```json
{
  "title": "Shipping Policy",
  "handle": "shipping-policy",
  "body_html": "<h2>Shipping Policy</h2><p>We ship worldwide...</p>",
  "status": "published"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `title` | string | Yes | Max: 255 |
| `handle` | string | No | Auto-generated. Unique per store. Max: 255. |
| `body_html` | string | No | Max: 65535 |
| `status` | string | No | One of: `draft`, `published`. Default: `draft`. |

**Success response:** `201 Created`

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors

---

#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}

Update a content page.

Same fields as POST. Supports partial updates.

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Page not found
- `422` - Validation errors

---

#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}

Delete a content page.

**Authentication:** Bearer token with `write-content` ability.

**Success response:** `200 OK`

```json
{
  "message": "Page deleted"
}
```


Output:
Chunk ID: 95bffa
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 649
Output:
#### Admin Pages Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/pages

List content pages.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | Filter: `draft`, `published`. |
| `page` | integer | No | Default: 1. |
| `per_page` | integer | No | Default: 25, max: 100. |

**Success response:** `200 OK`

```json
{
  "data": [
    {
      "id": 1,
      "store_id": 1,
      "title": "About Us",
      "handle": "about-us",
      "status": "published",
      "published_at": "2026-02-01T10:00:00Z",
      "created_at": "2026-01-15T10:00:00Z",
      "updated_at": "2026-02-01T10:00:00Z"
    }
  ],
  "meta": {
    "current_page": 1,
    "per_page": 25,
    "total": 3,
    "last_page": 1
  }
}
```

---

#### POST /api/admin/v1/stores/{storeId}/pages

Create a content page.

**Request body:**

```json
{
  "title": "Shipping Policy",
  "handle": "shipping-policy",
  "body_html": "<h2>Shipping Policy</h2><p>We ship worldwide...</p>",
  "status": "published"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `title` | string | Yes | Max: 255 |
| `handle` | string | No | Auto-generated. Unique per store. Max: 255. |
| `body_html` | string | No | Max: 65535 |
| `status` | string | No | One of: `draft`, `published`. Default: `draft`. |

**Success response:** `201 Created`

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors

---

#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}

Update a content page.

Same fields as POST. Supports partial updates.

**Success response:** `200 OK`

**Error responses:**
- `403` - Insufficient permissions
- `404` - Page not found
- `422` - Validation errors

---

#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}

Delete a content page.

**Authentication:** Bearer token with `write-content` ability.

**Success response:** `200 OK`

```json
{
  "message": "Page deleted"
}
```


Output:
Chunk ID: 3081a0
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 251
Output:
### pages

Static CMS pages (About Us, Contact, etc.).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| title | TEXT | No | - | - | Page title |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
| body_html | TEXT | Yes | NULL | - | Rich-text content |
| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |
| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store
- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status

---


varchar


The pages REST endpoints are explicitly specified and fit the remaining “content” gap. I’m checking the existing admin API patterns and page model/schema before editing so this slice follows the product/collection/discount conventions already in the app.


validation unique scoped ignore route model binding api resources pagination

JSON API testing validation errors resource collection


sed -n '1,260p' app/Http/Controllers/Api/Admin/V1/CollectionController.php


sed -n '1,240p' app/Http/Resources/Admin/V1/CollectionResource.php


rg -n "Page::|pages" database tests app/Livewire routes resources -g '*.php' -g '*.blade.php'


# Search Results

## Validation Available Validation Rules date_format:_format_,...
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

#### date_format:_format_,...

The field under validation must match one of the given _formats_. You should use **either** `date` or `date_format` when validating a field, not both. This validation rule supports all formats supported by PHP's [DateTime](https://www.php.net/manual/en/class.datetime.php) class.

For convenience, date-based rules may be constructed using the fluent `date` rule builder:

```php
use Illuminate\Validation\Rule;

'start_date' => [
    'required',
    Rule::date()->format('Y-m-d'),
],
```

---

## Expectations `json()`
Expectations | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/expectations.md

### `json()`

The `json()` modifier decodes the current expectation `$value` from JSON to an array.

```php
expect('{"name":"Nuno","credit":1000.00}')
    ->json()
    ->toHaveCount(2)
    ->name->toBe('Nuno')
    ->credit->toBeFloat();

expect('not-a-json')->json(); //Fails
```

---

## Http Tests Validation Assertions assertInvalid
Http Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md

#### assertInvalid

Assert that the response has validation errors for the given keys. This method may be used for asserting against responses where the validation errors are returned as a JSON structure or where the validation errors have been flashed to the session:

```php
$response->assertInvalid(['name', 'email']);
```

You may also assert that a given key has a particular validation error message. When doing so, you may provide the entire message or only a small portion of the message:

```php
$response->assertInvalid([
    'name' => 'The name field is required.',
    'email' => 'valid email address',
]);
```

---

## Validation Form Request Validation Preparing Input for Validation
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

### Preparing Input for Validation

If you need to prepare or sanitize any data from the request before you apply your validation rules, you may use the `prepareForValidation` method:

```php
use Illuminate\Support\Str;

/**
 * Prepare the data for validation.
 */
protected function prepareForValidation(): void
{
    $this->merge([
        'slug' => Str::slug($this->slug),
    ]);
}
```

Likewise, if you need to normalize any request data after validation is complete, you may use the `passedValidation` method:

```php
/**
 * Handle a passed validation attempt.
 */
protected function passedValidation(): void
{
    $this->replace(['name' => 'Taylor']);
}
```

---

## Validation Manually Creating Validators Performing Additional Validation
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

### Performing Additional Validation

Sometimes you need to perform additional validation after your initial validation is complete. You can accomplish this using the validator's `after` method. The `after` method accepts a closure or an array of callables which will be invoked after validation is complete. The given callables will receive an `Illuminate\Validation\Validator` instance, allowing you to raise additional error messages if necessary:

```php
use Illuminate\Support\Facades\Validator;

$validator = Validator::make(/* ... */);

$validator->after(function ($validator) {
    if ($this->somethingElseIsInvalid()) {
        $validator->errors()->add(
            'field', 'Something is wrong with this field!'
        );
    }
});

if ($validator->fails()) {
    // ...
}
```

As noted, the `after` method also accepts an array of callables, which is particularly convenient if your "after validation" logic is encapsulated in invokable classes, which will receive an `Illuminate\Validation\Validator` instance via their `__invoke` method:

```php
use App\Validation\ValidateShippingTime;
use App\Validation\ValidateUserStatus;

$validator->after([
    new ValidateUserStatus,
    new ValidateShippingTime,
    function ($validator) {
        // ...
    },
]);
```

---

## Validation Custom Validation Rules Implicit Rules
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

### Implicit Rules

By default, when an attribute being validated is not present or contains an empty string, normal validation rules, including custom rules, are not run. For example, the [unique](#rule-unique) rule will not be run against an empty string:

```php
use Illuminate\Support\Facades\Validator;

$rules = ['name' => 'unique:users,name'];

$input = ['name' => ''];

Validator::make($input, $rules)->passes(); // true
```

For a custom rule to run even when an attribute is empty, the rule must imply that the attribute is required. To quickly generate a new implicit rule object, you may use the `make:rule` Artisan command with the `--implicit` option:

```shell
php artisan make:rule Uppercase --implicit
```

> [!WARNING]
> An "implicit" rule only _implies_ that the attribute is required. Whether it actually invalidates a missing or empty attribute is up to you.

---

## Expectations `toBeJson()`
Expectations | pestphp/pest@4.x | Source: https://github.com/pestphp/docs/blob/4.x/expectations.md

### `toBeJson()`

This expectation ensures that `$value` is a JSON string.

```php
expect('{"hello":"world"}')->toBeJson();
```

---

## Http Tests Available Assertions Response Assertions assertJsonValidationErrors
Http Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md

#### assertJsonValidationErrors

Assert that the response has the given JSON validation errors for the given keys. This method should be used when asserting against responses where the validation errors are returned as a JSON structure instead of being flashed to the session:

```php
$response->assertJsonValidationErrors(array $data, $responseKey = 'errors');
```

> [!NOTE]
> The more generic [assertInvalid](#assert-invalid) method may be used to assert that a response has validation errors returned as JSON **or** that errors were flashed to session storage.

---

## Validation Available Validation Rules exclude
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

#### exclude

The field under validation will be excluded from the request data returned by the `validate` and `validated` methods.

---

## Validation Available Validation Rules json
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

#### json

The field under validation must be a valid JSON string.

---

## Validation Custom Validation Rules Using Rule Objects
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

### Using Rule Objects

Laravel provides a variety of helpful validation rules; however, you may wish to specify some of your own. One method of registering custom validation rules is using rule objects. To generate a new rule object, you may use the `make:rule` Artisan command. Let's use this command to generate a rule that verifies a string is uppercase. Laravel will place the new rule in the `app/Rules` directory. If this directory does not exist, Laravel will create it when you execute the Artisan command to create your rule:

```shell
php artisan make:rule Uppercase
```

Once the rule has been created, we are ready to define its behavior. A rule object contains a single method: `validate`. This method receives the attribute name, its value, and a callback that should be invoked on failure with the validation error message:

```php
<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class Uppercase implements ValidationRule
{
    /**
     * Run the validation rule.
     */
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (strtoupper($value) !== $value) {
            $fail('The :attribute must be uppercase.');
        }
    }
}
```

Once the rule has been defined, you may attach it to a validator by passing an instance of the rule object with your other validation rules:

```php
use App\Rules\Uppercase;

$request->validate([
    'name' => ['required', 'string', new Uppercase],
]);
```

#### Translating Validation Messages

Instead of providing a literal error message to the `$fail` closure, you may also provide a [translation string key](/docs/{{version}}/localization) and instruct Laravel to translate the error message:

```php
if (strtoupper($value) !== $value) {
    $fail('validation.uppercase')->translate();
}
```

If necessary, you may provide placeholder replacements and the preferred language as the first and second arguments to the `translate` method:

```php
$fail('validation.location')->translate([
    'value' => $this->value,
], 'fr');
```

#### Accessing Additional Data

If your custom validation rule class needs to access all of the other data undergoing validation, your rule class may implement the `Illuminate\Contracts\Validation\DataAwareRule` interface. This interface requires your class to define a `setData` method. This method will automatically be invoked by Laravel (before validation proceeds) with all of the data under validation:

```php
<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidationRule;

class Uppercase implements DataAwareRule, ValidationRule
{
    /**
     * All of the data under validation.
     *
     * @var array<string, mixed>
     */
    protected $data = [];

    // ...

    /**
     * Set the data under validation.
     *
     * @param  array<string, mixed>  $data
     */
    public function setData(array $data): static
    {
        $this->data = $data;

        return $this;
    }
}
```

Or, if your validation rule requires access to the validator instance performing the validation, you may implement the `ValidatorAwareRule` interface:

```php
<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Contracts\Validation\ValidatorAwareRule;
use Illuminate\Validation\Validator;

class Uppercase implements ValidationRule, ValidatorAwareRule
{
    /**
     * The validator instance.
     *
     * @var \Illuminate\Validation\Validator
     */
    protected $validator;

    // ...

    /**
     * Set the current validator.
     */
    public function setValidator(Validator $validator): static
    {
        $this->validator = $validator;

        return $this;
    }
}
```

---

## Validation Form Request Validation Customizing the Error Messages Customizing the Validation Attributes
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

#### Customizing the Validation Attributes

Many of Laravel's built-in validation rule error messages contain an `:attribute` placeholder. If you would like the `:attribute` placeholder of your validation message to be replaced with a custom attribute name, you may specify the custom names by overriding the `attributes` method. This method should return an array of attribute / name pairs:

```php
/**
 * Get custom attributes for validator errors.
 *
 * @return array<string, string>
 */
public function attributes(): array
{
    return [
        'email' => 'email address',
    ];
}
```

---

## Validation Form Request Validation
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

## Form Request Validation

---

## Strings Fluent Strings `isJson` {.collection-method}
Strings | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/strings.md

#### `isJson` {.collection-method}

The `isJson` method determines if a given string is valid JSON:

```php
use Illuminate\Support\Str;

$result = Str::of('[1,2,3]')->isJson();

// true

$result = Str::of('{"first": "John", "last": "Doe"}')->isJson();

// true

$result = Str::of('{first: "John", last: "Doe"}')->isJson();

// false
```

---

## Validation Form Request Validation Customizing the Error Messages
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

### Customizing the Error Messages

You may customize the error messages used by the form request by overriding the `messages` method. This method should return an array of attribute / rule pairs and their corresponding error messages:

```php
/**
 * Get the error messages for the defined validation rules.
 *
 * @return array<string, string>
 */
public function messages(): array
{
    return [
        'title.required' => 'A title is required',
        'body.required' => 'A message is required',
    ];
}
```

---

## Validation Custom Validation Rules Using Closures
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

### Using Closures

If you only need the functionality of a custom rule once throughout your application, you may use a closure instead of a rule object. The closure receives the attribute's name, the attribute's value, and a `$fail` callback that should be called if validation fails:

```php
use Illuminate\Support\Facades\Validator;
use Closure;

$validator = Validator::make($request->all(), [
    'title' => [
        'required',
        'max:255',
        function (string $attribute, mixed $value, Closure $fail) {
            if ($value === 'foo') {
                $fail("The {$attribute} is invalid.");
            }
        },
    ],
]);
```

---

## Validation Form Request Validation Creating Form Requests Customizing the Redirect Location
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

#### Customizing the Redirect Location

When form request validation fails, a redirect response will be generated to send the user back to their previous location. However, you are free to customize this behavior. To do so, define a `$redirect` property on your form request:

```php
/**
 * The URI that users should be redirected to if validation fails.
 *
 * @var string
 */
protected $redirect = '/dashboard';
```

Or, if you would like to redirect users to a named route, you may define a `$redirectRoute` property instead:

```php
/**
 * The route that users should be redirected to if validation fails.
 *
 * @var string
 */
protected $redirectRoute = 'dashboard';
```

---

## Validation Form Request Validation Creating Form Requests
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

### Creating Form Requests

For more complex validation scenarios, you may wish to create a "form request". Form requests are custom request classes that encapsulate their own validation and authorization logic. To create a form request class, you may use the `make:request` Artisan CLI command:

```shell
php artisan make:request StorePostRequest
```

The generated form request class will be placed in the `app/Http/Requests` directory. If this directory does not exist, it will be created when you run the `make:request` command. Each form request generated by Laravel has two methods: `authorize` and `rules`.

As you might have guessed, the `authorize` method is responsible for determining if the currently authenticated user can perform the action represented by the request, while the `rules` method returns the validation rules that should apply to the request's data:

```php
/**
 * Get the validation rules that apply to the request.
 *
 * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
 */
public function rules(): array
{
    return [
        'title' => 'required|unique:posts|max:255',
        'body' => 'required',
    ];
}
```

> [!NOTE]
> You may type-hint any dependencies you require within the `rules` method's signature. They will automatically be resolved via the Laravel [service container](/docs/{{version}}/container).

So, how are the validation rules evaluated? All you need to do is type-hint the request on your controller method. The incoming form request is validated before the controller method is called, meaning you do not need to clutter your controller with any validation logic:

```php
/**
 * Store a new blog post.
 */
public function store(StorePostRequest $request): RedirectResponse
{
    // The incoming request is valid...

    // Retrieve the validated input data...
    $validated = $request->validated();

    // Retrieve a portion of the validated input data...
    $validated = $request->safe()->only(['name', 'email']);
    $validated = $request->safe()->except(['name', 'email']);

    // Store the blog post...

    return redirect('/posts');
}
```

If validation fails, a redirect response will be generated to send the user back to their previous location. The errors will also be flashed to the session so they are available for display. If the request was an XHR request, an HTTP response with a 422 status code will be returned to the user including a [JSON representation of the validation errors](#validation-error-response-format).

> [!NOTE]
> Need to add real-time form request validation to your Inertia powered Laravel frontend? Check out [Laravel Precognition](/docs/{{version}}/precognition).

---

## Http Tests Testing JSON APIs
Http Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md

## Testing JSON APIs

Laravel also provides several helpers for testing JSON APIs and their responses. For example, the `json`, `getJson`, `postJson`, `putJson`, `patchJson`, `deleteJson`, and `optionsJson` methods may be used to issue JSON requests with various HTTP verbs. You may also easily pass data and headers to these methods. To get started, let's write a test to make a `POST` request to `/api/user` and assert that the expected JSON data was returned:

```php tab=Pest
<?php

test('making an api request', function () {
    $response = $this->postJson('/api/user', ['name' => 'Sally']);

    $response
        ->assertStatus(201)
        ->assertJson([
            'created' => true,
        ]);
});
```

```php tab=PHPUnit
<?php

namespace Tests\Feature;

use Tests\TestCase;

class ExampleTest extends TestCase
{
    /**
     * A basic functional test example.
     */
    public function test_making_an_api_request(): void
    {
        $response = $this->postJson('/api/user', ['name' => 'Sally']);

        $response
            ->assertStatus(201)
            ->assertJson([
                'created' => true,
            ]);
    }
}
```

In addition, JSON response data may be accessed as array variables on the response, making it convenient for you to inspect the individual values returned within a JSON response:

```php tab=Pest
expect($response['created'])->toBeTrue();
```

```php tab=PHPUnit
$this->assertTrue($response['created']);
```

> [!NOTE]
> The `assertJson` method converts the response to an array to verify that the given array exists within the JSON response returned by the application. So, if there are other properties in the JSON response, this test will still pass as long as the given fragment is present.

---

## Validation Custom Validation Rules
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

## Custom Validation Rules

---

## Urls URLs for Named Routes Signed URLs Validating Signed Route Requests
Urls | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/urls.md

#### Validating Signed Route Requests

To verify that an incoming request has a valid signature, you should call the `hasValidSignature` method on the incoming `Illuminate\Http\Request` instance:

```php
use Illuminate\Http\Request;

Route::get('/unsubscribe/{user}', function (Request $request) {
    if (! $request->hasValidSignature()) {
        abort(401);
    }

    // ...
})->name('unsubscribe');
```

Sometimes, you may need to allow your application's frontend to append data to a signed URL, such as when performing client-side pagination. Therefore, you can specify request query parameters that should be ignored when validating a signed URL using the `hasValidSignatureWhileIgnoring` method. Remember, ignoring parameters allows anyone to modify those parameters on the request:

```php
if (! $request->hasValidSignatureWhileIgnoring(['page', 'order'])) {
    abort(401);
}
```

Instead of validating signed URLs using the incoming request instance, you may assign the `signed` (`Illuminate\Routing\Middleware\ValidateSignature`) [middleware](/docs/{{version}}/middleware) to the route. If the incoming request does not have a valid signature, the middleware will automatically return a `403` HTTP response:

```php
Route::post('/unsubscribe/{user}', function (Request $request) {
    // ...
})->name('unsubscribe')->middleware('signed');
```

If your signed URLs do not include the domain in the URL hash, you should provide the `relative` argument to the middleware:

```php
Route::post('/unsubscribe/{user}', function (Request $request) {
    // ...
})->name('unsubscribe')->middleware('signed:relative');
```

---

## Strings Strings `Str::isJson()` {.collection-method}
Strings | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/strings.md

#### `Str::isJson()` {.collection-method}

The `Str::isJson` method determines if the given string is valid JSON:

```php
use Illuminate\Support\Str;

$result = Str::isJson('[1,2,3]');

// true

$result = Str::isJson('{"first": "John", "last": "Doe"}');

// true

$result = Str::isJson('{first: "John", last: "Doe"}');

// false
```

---

## Validation Form Request Validation Authorizing Form Requests
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

### Authorizing Form Requests

The form request class also contains an `authorize` method. Within this method, you may determine if the authenticated user actually has the authority to update a given resource. For example, you may determine if a user actually owns a blog comment they are attempting to update. Most likely, you will interact with your [authorization gates and policies](/docs/{{version}}/authorization) within this method:

```php
use App\Models\Comment;

/**
 * Determine if the user is authorized to make this request.
 */
public function authorize(): bool
{
    $comment = Comment::find($this->route('comment'));

    return $comment && $this->user()->can('update', $comment);
}
```

Since all form requests extend the base Laravel request class, we may use the `user` method to access the currently authenticated user. Also, note the call to the `route` method in the example above. This method grants you access to the URI parameters defined on the route being called, such as the `{comment}` parameter in the example below:

```php
Route::post('/comment/{comment}');
```

Therefore, if your application is taking advantage of [route model binding](/docs/{{version}}/routing#route-model-binding), your code may be made even more succinct by accessing the resolved model as a property of the request:

```php
return $this->user()->can('update', $this->comment);
```

If the `authorize` method returns `false`, an HTTP response with a 403 status code will automatically be returned and your controller method will not execute.

If you plan to handle authorization logic for the request in another part of your application, you may remove the `authorize` method completely, or simply return `true`:

```php
/**
 * Determine if the user is authorized to make this request.
 */
public function authorize(): bool
{
    return true;
}
```

> [!NOTE]
> You may type-hint any dependencies you need within the `authorize` method's signature. They will automatically be resolved via the Laravel [service container](/docs/{{version}}/container).

---

## Validation Form Request Validation Creating Form Requests Stopping on the First Validation Failure
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

#### Stopping on the First Validation Failure

By adding a `stopOnFirstFailure` property to your request class, you may inform the validator that it should stop validating all attributes once a single validation failure has occurred:

```php
/**
 * Indicates if the validator should stop on the first rule failure.
 *
 * @var bool
 */
protected $stopOnFirstFailure = true;
```

---

## Responses Streamed Responses Streamed JSON Responses
Responses | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/responses.md

### Streamed JSON Responses

If you need to stream JSON data incrementally, you may utilize the `streamJson` method. This method is especially useful for large datasets that need to be sent progressively to the browser in a format that can be easily parsed by JavaScript:

```php
use App\Models\User;

Route::get('/users.json', function () {
    return response()->streamJson([
        'users' => User::cursor(),
    ]);
});
```

The `useJsonStream` hook is identical to the [useStream hook](#consuming-streamed-responses) except that it will attempt to parse the data as JSON once it has finished streaming:

```tsx tab=React
import { useJsonStream } from "@laravel/stream-react";

type User = {
    id: number;
    name: string;
    email: string;
};

function App() {
    const { data, send } = useJsonStream<{ users: User[] }>("users");

    const loadUsers = () => {
        send({
            query: "taylor",
        });
    };

    return (
        <div>
            <ul>
                {data?.users.map((user) => (
                    <li>
                        {user.id}: {user.name}
                    </li>
                ))}
            </ul>
            <button onClick={loadUsers}>Load Users</button>
        </div>
    );
}
```

```vue tab=Vue
<script setup lang="ts">
import { useJsonStream } from "@laravel/stream-vue";

type User = {
    id: number;
    name: string;
    email: string;
};

const { data, send } = useJsonStream<{ users: User[] }>("users");

const loadUsers = () => {
    send({
        query: "taylor",
    });
};
</script>

<template>
    <div>
        <ul>
            <li v-for="user in data?.users" :key="user.id">
                {{ user.id }}: {{ user.name }}
            </li>
        </ul>
        <button @click="loadUsers">Load Users</button>
    </div>
</template>
```

```svelte tab=Svelte
<script>
import { useJsonStream } from "@laravel/stream-svelte";

const stream = useJsonStream("users");

const loadUsers = () => {
    stream.send({
        query: "taylor",
    });
};
</script>

<div>
    <ul>
        {#if $stream.data?.users}
            {#each $stream.data.users as user (user.id)}
                <li>{user.id}: {user.name}</li>
            {/each}
        {/if}
    </ul>
    <button onclick={loadUsers}>Load Users</button>
</div>
```

---

## Http Tests Testing JSON APIs Fluent JSON Testing Scoping JSON Collection Assertions
Http Tests | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/http-tests.md

#### Scoping JSON Collection Assertions

Sometimes, your application's routes will return JSON collections that are assigned named keys:

```php
Route::get('/users', function () {
    return [
        'meta' => [...],
        'users' => User::all(),
    ];
})
```

When testing these routes, you may use the `has` method to assert against the number of items in the collection. In addition, you may use the `has` method to scope a chain of assertions:

```php
$response
    ->assertJson(fn (AssertableJson $json) =>
        $json->has('meta')
            ->has('users', 3)
            ->has('users.0', fn (AssertableJson $json) =>
                $json->where('id', 1)
                    ->where('name', 'Victoria Faith')
                    ->where('email', fn (string $email) => str($email)->is('victoria@gmail.com'))
                    ->missing('password')
                    ->etc()
            )
    );
```

However, instead of making two separate calls to the `has` method to assert against the `users` collection, you may make a single call which provides a closure as its third parameter. When doing so, the closure will automatically be invoked and scoped to the first item in the collection:

```php
$response
    ->assertJson(fn (AssertableJson $json) =>
        $json->has('meta')
            ->has('users', 3, fn (AssertableJson $json) =>
                $json->where('id', 1)
                    ->where('name', 'Victoria Faith')
                    ->where('email', fn (string $email) => str($email)->is('victoria@gmail.com'))
                    ->missing('password')
                    ->etc()
            )
    );
```

---

## Validation Validating Passwords Defining Default Password Rules
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

#### Defining Default Password Rules

You may find it convenient to specify the default validation rules for passwords in a single location of your application. You can easily accomplish this using the `Password::defaults` method, which accepts a closure. The closure given to the `defaults` method should return the default configuration of the Password rule. Typically, the `defaults` rule should be called within the `boot` method of one of your application's service providers:

```php
use Illuminate\Validation\Rules\Password;

/**
 * Bootstrap any application services.
 */
public function boot(): void
{
    Password::defaults(function () {
        $rule = Password::min(8);

        return $this->app->isProduction()
            ? $rule->mixedCase()->uncompromised()
            : $rule;
    });
}
```

Then, when you would like to apply the default rules to a particular password undergoing validation, you may invoke the `defaults` method with no arguments:

```php
'password' => ['required', Password::defaults()],
```

Occasionally, you may want to attach additional validation rules to your default password validation rules. You may use the `rules` method to accomplish this:

```php
use App\Rules\ZxcvbnRule;

Password::defaults(function () {
    $rule = Password::min(8)->rules([new ZxcvbnRule]);

    // ...
});
```

---

## Validation Conditionally Adding Rules Skipping Validation When Fields Have Certain Values
Validation | laravel/framework@12.x | Source: https://github.com/laravel/docs/blob/12.x/validation.md

#### Skipping Validation When Fields Have Certain Values

You may occasionally wish to not validate a given field if another field has a given value. You may accomplish this using the `exclude_if` validation rule. In this example, the `appointment_date` and `doctor_name` fields will not be validated if the `has_appointment` field has a value of `false`:

```php
use Illuminate\Support\Facades\Validator;

$validator = Validator::make($data, [
    'has_appointment' => 'required|boolean',
    'appointment_date' => 'exclude_if:has_appointment,false|required|date',
    'doctor_name' => 'exclude_if:has_appointment,false|required|string',
]);
```

Alternatively, you may use the `exclude_unless` rule to not validate a given field unless another field has a given value:

```php
$validator = Validator::make($data, [
    'has_appointment' => 'required|boolean',
    'appointment_date' => 'exclude_unless:has_appointment,true|required|date',
    'doctor_name' => 'exclude_unless:has_appointment,true|required|string',
]);
```

_[Truncated]_