Compare commits
10 Commits
02d4c9d888
...
ec3ad9130c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec3ad9130c | ||
|
|
ada43d222a | ||
|
|
a83d06d76a | ||
|
|
80a8a9f93b | ||
|
|
7f153fb08d | ||
|
|
ec3a2bf848 | ||
|
|
7d5b467084 | ||
|
|
e39453f4f6 | ||
|
|
c8bc2836b6 | ||
|
|
5ad89e977d |
432
.claude/rules/api-data.md
Normal file
432
.claude/rules/api-data.md
Normal file
@@ -0,0 +1,432 @@
|
||||
# External API & Data Sources
|
||||
|
||||
## UK Fuel Finder API (gov.uk) — PRIMARY SOURCE
|
||||
|
||||
- Base URL: `https://www.fuel-finder.service.gov.uk/api/v1/`
|
||||
- Returns: all UK station prices + station metadata (~14,500 stations)
|
||||
- Update frequency: stations report within 30 minutes of price change
|
||||
- Our polling interval: every 15 minutes via scheduler (incremental), full refresh once daily
|
||||
|
||||
### Authentication
|
||||
|
||||
OAuth 2.0 using JSON body POST (not form-encoded). Credentials in `.env` as `FUEL_FINDER_CLIENT_ID` / `FUEL_FINDER_CLIENT_SECRET`.
|
||||
|
||||
**Get token:**
|
||||
```
|
||||
POST /api/v1/oauth/generate_access_token
|
||||
Content-Type: application/json
|
||||
{"client_id": "...", "client_secret": "..."}
|
||||
|
||||
Response: {"access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "eyJ..."}
|
||||
```
|
||||
|
||||
**Refresh token:**
|
||||
```
|
||||
POST /api/v1/oauth/regenerate_access_token
|
||||
Content-Type: application/json
|
||||
{"client_id": "...", "client_secret": "..."}
|
||||
```
|
||||
|
||||
**Token caching strategy (FuelPriceService):**
|
||||
- Cache the `access_token` with TTL = `expires_in` - 60 seconds (3540s)
|
||||
- Cache key: `fuel_finder_access_token`
|
||||
- On cache miss: call `generate_access_token`, store result, return token
|
||||
- Use the `refresh_token` to regenerate before expiry if needed
|
||||
- Include token in every API request: `Authorization: Bearer {token}`
|
||||
|
||||
Fuel Finder REST API
|
||||
The Fuel Finder API is a REST API that gives a simple, consistent way to request, create and update data. REST stands for Representational State Transfer which is an architectural software style in which standard HTTP request methods are used to retrieve and modify representations of data. This is identical to the process of retrieving a web page or submitting a web form.
|
||||
|
||||
Representational State Transfer (REST) web services
|
||||
In a RESTful API, each data resource has a unique URL and is manipulated using standard HTTP verbs such as:
|
||||
|
||||
GET to request a resource
|
||||
POST to create a resource (not used for read-only endpoints)
|
||||
PUT to change a resource (not used for read-only endpoints)
|
||||
DELETE to remove a resource (not used for read-only endpoints)
|
||||
Example: request a price resource
|
||||
GET: https://api.fuelfinder.service.gov.uk/v1/prices/GB-12345 HTTP/1.1
|
||||
The request uses GET and does not include a request body.
|
||||
|
||||
In a RESTful API, a resource is modified by POSTing a revised resource representation, in this case JSON, to the same resource URL:
|
||||
|
||||
POST: https://api.fuelfinder.service.gov.uk/v1/<endpoint>
|
||||
Content-Type: text/json
|
||||
{
|
||||
"CustomerName": "Joe Bloggs",
|
||||
"Address": "",
|
||||
"etc": etc
|
||||
}
|
||||
REST builds on the features of HTTP. Because each resource has a globally unique URL and can be fetched with GET, REST APIs can benefit from existing network components such as caches and proxies.
|
||||
|
||||
The JSON data format
|
||||
Responses use JSON (JavaScript Object Notation). JSON is a compact, widely used format for storing and exchanging data. Most programming languages support JSON, which makes it well suited to HTTP-based API services.
|
||||
|
||||
#### Endpoints
|
||||
- Endpoints
|
||||
- Method Endpoint
|
||||
- GET Fetch all PFS fuel prices
|
||||
- GET Fetch incremental PFS fuel prices
|
||||
- GET Fetch PFS information
|
||||
- GET Fetch incremental PFS information
|
||||
|
||||
|
||||
```
|
||||
https://www.fuel-finder.service.gov.uk/api/v1/pfs/fuel-prices?batch-number
|
||||
[
|
||||
{
|
||||
"node_id": "0028acef5f3afc41c7e7d56fb285a940dfb64d6fea01cb4accd79c148321112d",
|
||||
"public_phone_number": null,
|
||||
"trading_name": "Alex Fuel Station",
|
||||
"fuel_prices": [
|
||||
{
|
||||
"fuel_type": "E5",
|
||||
"price": 159.9,
|
||||
"price_last_updated": "2026-02-17T16:03:04.938Z",
|
||||
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"fuel_type": "E10",
|
||||
"price": 132.9,
|
||||
"price_last_updated": "2026-02-17T16:03:04.938Z",
|
||||
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"fuel_type": "B7_STANDARD",
|
||||
"price": 141.9,
|
||||
"price_last_updated": "2026-02-17T16:03:04.938Z",
|
||||
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"node_id": "01da92125c3751767044d06b202f45da5933f0e16e256fa3e98a16af8386308d",
|
||||
"public_phone_number": "",
|
||||
"trading_name": "Star Garage",
|
||||
"fuel_prices": [
|
||||
{
|
||||
"fuel_type": "E5",
|
||||
"price": 159.9,
|
||||
"price_last_updated": "2026-02-17T16:03:04.938Z",
|
||||
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"node_id": "020592cd81196efdb61ab2135f837ddf3d2bee4e64346810270f0b088b4c09d8",
|
||||
"public_phone_number": null,
|
||||
"trading_name": "Blue Hills Fuel Station",
|
||||
"fuel_prices": [
|
||||
{
|
||||
"fuel_type": "E5",
|
||||
"price": 159.9,
|
||||
"price_last_updated": "2026-02-17T16:03:04.938Z",
|
||||
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"fuel_type": "B7_STANDARD",
|
||||
"price": 141.9,
|
||||
"price_last_updated": "2026-02-17T16:03:04.938Z",
|
||||
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
```
|
||||
https://www.fuel-finder.service.gov.uk/api/v1/pfs?batch-number=1
|
||||
[
|
||||
{
|
||||
"node_id": "9b275ab576eeba3c6677984be15ee22a74e54fdfe8e5ea700e84a03178dc4ac1",
|
||||
"public_phone_number": null,
|
||||
"trading_name": "TEST",
|
||||
"is_same_trading_and_brand_name": true,
|
||||
"brand_name": "TEST",
|
||||
"temporary_closure": false,
|
||||
"permanent_closure": false,
|
||||
"permanent_closure_date": null,
|
||||
"is_motorway_service_station": false,
|
||||
"is_supermarket_service_station": false,
|
||||
"location": {
|
||||
"address_line_1": "HALL & WOODHOUSE, TAPLOW BOATYARD, MILL LANE, TAPLOW, MAIDENHEAD, SL6 0AA",
|
||||
"address_line_2": null,
|
||||
"city": "MAIDENHEAD",
|
||||
"country": "England",
|
||||
"county": null,
|
||||
"postcode": "SL6 0AA",
|
||||
"latitude": 51.5268585,
|
||||
"longitude": -0.700361
|
||||
},
|
||||
"amenities": [
|
||||
"water_filling"
|
||||
],
|
||||
"opening_times": {
|
||||
"usual_days": {
|
||||
"monday": {
|
||||
"open": "00:00:00",
|
||||
"close": "00:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"tuesday": {
|
||||
"open": "00:00:00",
|
||||
"close": "00:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"wednesday": {
|
||||
"open": "00:00:00",
|
||||
"close": "00:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"thursday": {
|
||||
"open": "00:00:00",
|
||||
"close": "00:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"friday": {
|
||||
"open": "00:00:00",
|
||||
"close": "00:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"saturday": {
|
||||
"open": "00:00:00",
|
||||
"close": "00:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"sunday": {
|
||||
"open": "00:00:00",
|
||||
"close": "23:59:00",
|
||||
"is_24_hours": true
|
||||
}
|
||||
},
|
||||
"bank_holiday": {
|
||||
"type": "bank holiday",
|
||||
"open_time": "00:00:00",
|
||||
"close_time": "00:00:00",
|
||||
"is_24_hours": false
|
||||
}
|
||||
},
|
||||
"fuel_types": [
|
||||
"E10",
|
||||
"E5",
|
||||
"HVO",
|
||||
"B10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"node_id": "4fd9a4c6b48358b9b5c95989fba100fdcbb87c9e909ed4ce1ad96f64ffb8b56a",
|
||||
"public_phone_number": "+44 7723608248",
|
||||
"trading_name": "TEST FORECOURT 1",
|
||||
"is_same_trading_and_brand_name": true,
|
||||
"brand_name": "TEXACO ONE",
|
||||
"temporary_closure": false,
|
||||
"permanent_closure": null,
|
||||
"permanent_closure_date": null,
|
||||
"is_motorway_service_station": false,
|
||||
"is_supermarket_service_station": false,
|
||||
"location": {
|
||||
"address_line_1": "NEWPORT",
|
||||
"address_line_2": "",
|
||||
"city": "BROUGH",
|
||||
"country": "ENGLAND",
|
||||
"county": "EAST YORKSHIRE",
|
||||
"postcode": "HU15 2RD",
|
||||
"latitude": 51.258503,
|
||||
"longitude": -3.417567
|
||||
},
|
||||
"amenities": [
|
||||
"adblue_packaged",
|
||||
"adblue_pumps",
|
||||
"car_wash",
|
||||
"customer_toilets"
|
||||
],
|
||||
"opening_times": {
|
||||
"usual_days": {
|
||||
"monday": {
|
||||
"open": "06:00:01",
|
||||
"close": "23:00:01",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"tuesday": {
|
||||
"open": "06:00:01",
|
||||
"close": "23:00:01",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"wednesday": {
|
||||
"open": "06:00:01",
|
||||
"close": "23:00:01",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"thursday": {
|
||||
"open": "06:00:01",
|
||||
"close": "23:00:01",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"friday": {
|
||||
"open": "06:00:01",
|
||||
"close": "23:00:01",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"saturday": {
|
||||
"open": "06:00:01",
|
||||
"close": "23:00:01",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"sunday": {
|
||||
"open": "06:00:01",
|
||||
"close": "23:00:01",
|
||||
"is_24_hours": false
|
||||
}
|
||||
},
|
||||
"bank_holiday": {
|
||||
"type": "standard",
|
||||
"open_time": "06:00:01",
|
||||
"close_time": "23:00:01",
|
||||
"is_24_hours": false
|
||||
}
|
||||
},
|
||||
"fuel_types": [
|
||||
"B10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"node_id": "91bdda1c07fa05110a31639cc66932f9ed8bd388d4f6be542a423365bcfd53e1",
|
||||
"public_phone_number": "+442071930000",
|
||||
"trading_name": "SUPERFUEL LOUGHBOROUGH 12",
|
||||
"is_same_trading_and_brand_name": true,
|
||||
"brand_name": "SUPERFUEL STATION 4",
|
||||
"temporary_closure": false,
|
||||
"permanent_closure": null,
|
||||
"permanent_closure_date": null,
|
||||
"is_motorway_service_station": false,
|
||||
"is_supermarket_service_station": false,
|
||||
"location": {
|
||||
"address_line_1": "14 LONDON ROAD",
|
||||
"address_line_2": "FUELVILLE",
|
||||
"city": "LOUGHBOROUGH",
|
||||
"country": "ENGLAND",
|
||||
"county": "LEICESTERSHIRE",
|
||||
"postcode": "LE11 9AA",
|
||||
"latitude": 50.503343,
|
||||
"longitude": -2.12444
|
||||
},
|
||||
"amenities": [
|
||||
"adblue_packaged",
|
||||
"adblue_pumps",
|
||||
"car_wash",
|
||||
"customer_toilets",
|
||||
"water_filling"
|
||||
],
|
||||
"opening_times": {
|
||||
"usual_days": {
|
||||
"monday": {
|
||||
"open": "06:00:00",
|
||||
"close": "22:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"tuesday": {
|
||||
"open": "06:00:00",
|
||||
"close": "22:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"wednesday": {
|
||||
"open": "06:00:00",
|
||||
"close": "22:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"thursday": {
|
||||
"open": "06:00:00",
|
||||
"close": "22:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"friday": {
|
||||
"open": "06:00:00",
|
||||
"close": "22:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"saturday": {
|
||||
"open": "06:00:00",
|
||||
"close": "22:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"sunday": {
|
||||
"open": "06:00:00",
|
||||
"close": "22:00:00",
|
||||
"is_24_hours": false
|
||||
}
|
||||
},
|
||||
"bank_holiday": {
|
||||
"type": "standard",
|
||||
"open_time": "08:00:00",
|
||||
"close_time": "20:00:00",
|
||||
"is_24_hours": false
|
||||
}
|
||||
},
|
||||
"fuel_types": [
|
||||
"E5",
|
||||
"HVO",
|
||||
"B10",
|
||||
"B7_PREMIUM",
|
||||
"B7_STANDARD"
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### FuelPriceService responsibilities
|
||||
1. Fetch OAuth token (cache it)
|
||||
2. GET all station prices
|
||||
3. Upsert `stations` table with metadata
|
||||
4. Insert new rows into `station_prices` only when price has changed for that station+fuel combo
|
||||
5. Call StationTaggingService to set `is_supermarket` and `brand`
|
||||
6. Dispatch `PricesUpdatedEvent` for downstream processing
|
||||
|
||||
### Deduplication
|
||||
Only insert a new `station_prices` row if price differs from the most recent stored price
|
||||
for that `(station_id, fuel_type)` combination. Avoids row explosion on unchanged prices.
|
||||
|
||||
### Credentials in .env
|
||||
```
|
||||
FUEL_FINDER_CLIENT_ID=
|
||||
FUEL_FINDER_CLIENT_SECRET=
|
||||
FUEL_FINDER_BASE_URL=https://api.fuel-finder.service.gov.uk
|
||||
```
|
||||
|
||||
## Postcodes.io — postcode → lat/lng
|
||||
|
||||
- URL: `https://api.postcodes.io/postcodes/{postcode}`
|
||||
- Free, no API key required
|
||||
- Called once on user registration / when postcode changes
|
||||
- Store resolved `lat` + `lng` on `users` table
|
||||
- Cache postcode lookups for 30 days (postcodes rarely change coordinates)
|
||||
|
||||
## FRED API (St. Louis Fed) — Brent crude direction
|
||||
|
||||
- Series: `DCOILBRENTEU` (daily Brent spot price)
|
||||
- URL: `https://api.stlouisfed.org/fred/series/observations?series_id=DCOILBRENTEU&api_key={key}&sort_order=desc&limit=10&file_type=json`
|
||||
- Free API key required — stored as `FRED_API_KEY` in .env
|
||||
- Fetched once daily via scheduler at 7am
|
||||
- Stored in `brent_prices` table: `(date DATE, price_usd DECIMAL(8,2))`
|
||||
- Only the 5-day trend direction is used by the scoring engine
|
||||
|
||||
## OneSignal — push notifications
|
||||
|
||||
- REST API: `https://oapi.onesignal.com/notifications`
|
||||
- App ID + REST API key stored in .env as `ONESIGNAL_APP_ID`, `ONESIGNAL_API_KEY`
|
||||
- Target by `player_id` (stored in `users.push_token`)
|
||||
- No official Laravel package needed — use Laravel HTTP client (`Http::post(...)`)
|
||||
- Free plan: 10,000 subscribers — sufficient for v1
|
||||
|
||||
## Vonage — WhatsApp + SMS
|
||||
|
||||
- Package: `vonage/client-core` via Composer
|
||||
- Credentials: `VONAGE_KEY`, `VONAGE_SECRET`, `VONAGE_WHATSAPP_FROM` in .env
|
||||
- WhatsApp: Messages API, utility template category (pre-approved)
|
||||
- SMS: SMS API, alphanumeric sender ID "FuelAlert"
|
||||
- All Vonage calls go through NotificationDispatchService — never call Vonage directly from components
|
||||
|
||||
## HTTP client
|
||||
|
||||
Use Laravel's built-in `Http` facade for all external API calls.
|
||||
Always set a timeout: `Http::timeout(10)->get(...)`.
|
||||
Wrap in try/catch — log failures, never let a failed API call crash the scheduler.
|
||||
@@ -26,7 +26,7 @@ Tier is read via `$user->subscribed('basic')`, `->subscribed('plus')`, `->subscr
|
||||
```
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT
|
||||
station_id VARCHAR(64) — Fuel Finder API station identifier
|
||||
fuel_type ENUM('e10','e5','diesel','super_diesel','b10','hvo')
|
||||
fuel_type ENUM('e10','e5','b7_standard','b7_premium','b10','hvo')
|
||||
price_pence SMALLINT UNSIGNED — e.g. 14523 = 145.23p (store as integer × 100)
|
||||
is_supermarket TINYINT(1) DEFAULT 0
|
||||
brand VARCHAR(64) NULLABLE
|
||||
81
.claude/skills/fluxui-development/SKILL.md
Normal file
81
.claude/skills/fluxui-development/SKILL.md
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: fluxui-development
|
||||
description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Flux UI Development
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Flux UI patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components.
|
||||
|
||||
Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize.
|
||||
|
||||
Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs.
|
||||
|
||||
<!-- Basic Button -->
|
||||
```blade
|
||||
<flux:button variant="primary">Click me</flux:button>
|
||||
```
|
||||
|
||||
## Available Components (Free Edition)
|
||||
|
||||
Available: avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, tooltip
|
||||
|
||||
## Icons
|
||||
|
||||
Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names.
|
||||
|
||||
<!-- Icon Button -->
|
||||
```blade
|
||||
<flux:button icon="arrow-down-tray">Export</flux:button>
|
||||
```
|
||||
|
||||
For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command:
|
||||
|
||||
```bash
|
||||
php artisan flux:icon crown grip-vertical github
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Form Fields
|
||||
|
||||
<!-- Form Field -->
|
||||
```blade
|
||||
<flux:field>
|
||||
<flux:label>Email</flux:label>
|
||||
<flux:input type="email" wire:model="email" />
|
||||
<flux:error name="email" />
|
||||
</flux:field>
|
||||
```
|
||||
|
||||
### Modals
|
||||
|
||||
<!-- Modal -->
|
||||
```blade
|
||||
<flux:modal wire:model="showModal">
|
||||
<flux:heading>Title</flux:heading>
|
||||
<p>Content</p>
|
||||
</flux:modal>
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. Check component renders correctly
|
||||
2. Test interactive states
|
||||
3. Verify mobile responsiveness
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Trying to use Pro-only components in the free edition
|
||||
- Not checking if a Flux component exists before creating custom implementations
|
||||
- Forgetting to use the `search-docs` tool for component-specific documentation
|
||||
- Not following existing project patterns for Flux usage
|
||||
190
.claude/skills/laravel-best-practices/SKILL.md
Normal file
190
.claude/skills/laravel-best-practices/SKILL.md
Normal file
@@ -0,0 +1,190 @@
|
||||
---
|
||||
name: laravel-best-practices
|
||||
description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Laravel Best Practices
|
||||
|
||||
Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`.
|
||||
|
||||
## Consistency First
|
||||
|
||||
Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
|
||||
|
||||
Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 1. Database Performance → `rules/db-performance.md`
|
||||
|
||||
- Eager load with `with()` to prevent N+1 queries
|
||||
- Enable `Model::preventLazyLoading()` in development
|
||||
- Select only needed columns, avoid `SELECT *`
|
||||
- `chunk()` / `chunkById()` for large datasets
|
||||
- Index columns used in `WHERE`, `ORDER BY`, `JOIN`
|
||||
- `withCount()` instead of loading relations to count
|
||||
- `cursor()` for memory-efficient read-only iteration
|
||||
- Never query in Blade templates
|
||||
|
||||
### 2. Advanced Query Patterns → `rules/advanced-queries.md`
|
||||
|
||||
- `addSelect()` subqueries over eager-loading entire has-many for a single value
|
||||
- Dynamic relationships via subquery FK + `belongsTo`
|
||||
- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries
|
||||
- `setRelation()` to prevent circular N+1 queries
|
||||
- `whereIn` + `pluck()` over `whereHas` for better index usage
|
||||
- Two simple queries can beat one complex query
|
||||
- Compound indexes matching `orderBy` column order
|
||||
- Correlated subqueries in `orderBy` for has-many sorting (avoid joins)
|
||||
|
||||
### 3. Security → `rules/security.md`
|
||||
|
||||
- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates
|
||||
- No raw SQL with user input — use Eloquent or query builder
|
||||
- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes
|
||||
- Validate MIME type, extension, and size for file uploads
|
||||
- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields
|
||||
|
||||
### 4. Caching → `rules/caching.md`
|
||||
|
||||
- `Cache::remember()` over manual get/put
|
||||
- `Cache::flexible()` for stale-while-revalidate on high-traffic data
|
||||
- `Cache::memo()` to avoid redundant cache hits within a request
|
||||
- Cache tags to invalidate related groups
|
||||
- `Cache::add()` for atomic conditional writes
|
||||
- `once()` to memoize per-request or per-object lifetime
|
||||
- `Cache::lock()` / `lockForUpdate()` for race conditions
|
||||
- Failover cache stores in production
|
||||
|
||||
### 5. Eloquent Patterns → `rules/eloquent.md`
|
||||
|
||||
- Correct relationship types with return type hints
|
||||
- Local scopes for reusable query constraints
|
||||
- Global scopes sparingly — document their existence
|
||||
- Attribute casts in the `casts()` method
|
||||
- Cast date columns, use Carbon instances in templates
|
||||
- `whereBelongsTo($model)` for cleaner queries
|
||||
- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries
|
||||
|
||||
### 6. Validation & Forms → `rules/validation.md`
|
||||
|
||||
- Form Request classes, not inline validation
|
||||
- Array notation `['required', 'email']` for new code; follow existing convention
|
||||
- `$request->validated()` only — never `$request->all()`
|
||||
- `Rule::when()` for conditional validation
|
||||
- `after()` instead of `withValidator()`
|
||||
|
||||
### 7. Configuration → `rules/config.md`
|
||||
|
||||
- `env()` only inside config files
|
||||
- `App::environment()` or `app()->isProduction()`
|
||||
- Config, lang files, and constants over hardcoded text
|
||||
|
||||
### 8. Testing Patterns → `rules/testing.md`
|
||||
|
||||
- `LazilyRefreshDatabase` over `RefreshDatabase` for speed
|
||||
- `assertModelExists()` over raw `assertDatabaseHas()`
|
||||
- Factory states and sequences over manual overrides
|
||||
- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before
|
||||
- `recycle()` to share relationship instances across factories
|
||||
|
||||
### 9. Queue & Job Patterns → `rules/queue-jobs.md`
|
||||
|
||||
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
|
||||
- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency
|
||||
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
|
||||
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
|
||||
- Horizon for complex multi-queue scenarios
|
||||
|
||||
### 10. Routing & Controllers → `rules/routing.md`
|
||||
|
||||
- Implicit route model binding
|
||||
- Scoped bindings for nested resources
|
||||
- `Route::resource()` or `apiResource()`
|
||||
- Methods under 10 lines — extract to actions/services
|
||||
- Type-hint Form Requests for auto-validation
|
||||
|
||||
### 11. HTTP Client → `rules/http-client.md`
|
||||
|
||||
- Explicit `timeout` and `connectTimeout` on every request
|
||||
- `retry()` with exponential backoff for external APIs
|
||||
- Check response status or use `throw()`
|
||||
- `Http::pool()` for concurrent independent requests
|
||||
- `Http::fake()` and `preventStrayRequests()` in tests
|
||||
|
||||
### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md`
|
||||
|
||||
- Event discovery over manual registration; `event:cache` in production
|
||||
- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions
|
||||
- Queue notifications and mailables with `ShouldQueue`
|
||||
- On-demand notifications for non-user recipients
|
||||
- `HasLocalePreference` on notifiable models
|
||||
- `assertQueued()` not `assertSent()` for queued mailables
|
||||
- Markdown mailables for transactional emails
|
||||
|
||||
### 13. Error Handling → `rules/error-handling.md`
|
||||
|
||||
- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern
|
||||
- `ShouldntReport` for exceptions that should never log
|
||||
- Throttle high-volume exceptions to protect log sinks
|
||||
- `dontReportDuplicates()` for multi-catch scenarios
|
||||
- Force JSON rendering for API routes
|
||||
- Structured context via `context()` on exception classes
|
||||
|
||||
### 14. Task Scheduling → `rules/scheduling.md`
|
||||
|
||||
- `withoutOverlapping()` on variable-duration tasks
|
||||
- `onOneServer()` on multi-server deployments
|
||||
- `runInBackground()` for concurrent long tasks
|
||||
- `environments()` to restrict to appropriate environments
|
||||
- `takeUntilTimeout()` for time-bounded processing
|
||||
- Schedule groups for shared configuration
|
||||
|
||||
### 15. Architecture → `rules/architecture.md`
|
||||
|
||||
- Single-purpose Action classes; dependency injection over `app()` helper
|
||||
- Prefer official Laravel packages and follow conventions, don't override defaults
|
||||
- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety
|
||||
- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution
|
||||
|
||||
### 16. Migrations → `rules/migrations.md`
|
||||
|
||||
- Generate migrations with `php artisan make:migration`
|
||||
- `constrained()` for foreign keys
|
||||
- Never modify migrations that have run in production
|
||||
- Add indexes in the migration, not as an afterthought
|
||||
- Mirror column defaults in model `$attributes`
|
||||
- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes
|
||||
- One concern per migration — never mix DDL and DML
|
||||
|
||||
### 17. Collections → `rules/collections.md`
|
||||
|
||||
- Higher-order messages for simple collection operations
|
||||
- `cursor()` vs. `lazy()` — choose based on relationship needs
|
||||
- `lazyById()` when updating records while iterating
|
||||
- `toQuery()` for bulk operations on collections
|
||||
|
||||
### 18. Blade & Views → `rules/blade-views.md`
|
||||
|
||||
- `$attributes->merge()` in component templates
|
||||
- Blade components over `@include`; `@pushOnce` for per-component scripts
|
||||
- View Composers for shared view data
|
||||
- `@aware` for deeply nested component props
|
||||
|
||||
### 19. Conventions & Style → `rules/style.md`
|
||||
|
||||
- Follow Laravel naming conventions for all entities
|
||||
- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions
|
||||
- No JS/CSS in Blade, no HTML in PHP classes
|
||||
- Code should be readable; comments only for config files
|
||||
|
||||
## How to Apply
|
||||
|
||||
Always use a sub-agent to read rule files and explore this skill's content.
|
||||
|
||||
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
|
||||
2. Check sibling files for existing patterns — follow those first per Consistency First
|
||||
3. Verify API syntax with `search-docs` for the installed Laravel version
|
||||
106
.claude/skills/laravel-best-practices/rules/advanced-queries.md
Normal file
106
.claude/skills/laravel-best-practices/rules/advanced-queries.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Advanced Query Patterns
|
||||
|
||||
## Use `addSelect()` Subqueries for Single Values from Has-Many
|
||||
|
||||
Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries.
|
||||
|
||||
```php
|
||||
public function scopeWithLastLoginAt($query): void
|
||||
{
|
||||
$query->addSelect([
|
||||
'last_login_at' => Login::select('created_at')
|
||||
->whereColumn('user_id', 'users.id')
|
||||
->latest()
|
||||
->take(1),
|
||||
])->withCasts(['last_login_at' => 'datetime']);
|
||||
}
|
||||
```
|
||||
|
||||
## Create Dynamic Relationships via Subquery FK
|
||||
|
||||
Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection.
|
||||
|
||||
```php
|
||||
public function lastLogin(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Login::class);
|
||||
}
|
||||
|
||||
public function scopeWithLastLogin($query): void
|
||||
{
|
||||
$query->addSelect([
|
||||
'last_login_id' => Login::select('id')
|
||||
->whereColumn('user_id', 'users.id')
|
||||
->latest()
|
||||
->take(1),
|
||||
])->with('lastLogin');
|
||||
}
|
||||
```
|
||||
|
||||
## Use Conditional Aggregates Instead of Multiple Count Queries
|
||||
|
||||
Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values.
|
||||
|
||||
```php
|
||||
$statuses = Feature::toBase()
|
||||
->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
|
||||
->selectRaw("count(case when status = 'Planned' then 1 end) as planned")
|
||||
->selectRaw("count(case when status = 'Completed' then 1 end) as completed")
|
||||
->first();
|
||||
```
|
||||
|
||||
## Use `setRelation()` to Prevent Circular N+1
|
||||
|
||||
When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries.
|
||||
|
||||
```php
|
||||
$feature->load('comments.user');
|
||||
$feature->comments->each->setRelation('feature', $feature);
|
||||
```
|
||||
|
||||
## Prefer `whereIn` + Subquery Over `whereHas`
|
||||
|
||||
`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory.
|
||||
|
||||
Incorrect (correlated EXISTS re-executes per row):
|
||||
|
||||
```php
|
||||
$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
|
||||
```
|
||||
|
||||
Correct (index-friendly subquery, no PHP memory overhead):
|
||||
|
||||
```php
|
||||
$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
|
||||
```
|
||||
|
||||
## Sometimes Two Simple Queries Beat One Complex Query
|
||||
|
||||
Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index.
|
||||
|
||||
## Use Compound Indexes Matching `orderBy` Column Order
|
||||
|
||||
When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index.
|
||||
|
||||
```php
|
||||
// Migration
|
||||
$table->index(['last_name', 'first_name']);
|
||||
|
||||
// Query — column order must match the index
|
||||
User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
|
||||
```
|
||||
|
||||
## Use Correlated Subqueries for Has-Many Ordering
|
||||
|
||||
When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading.
|
||||
|
||||
```php
|
||||
public function scopeOrderByLastLogin($query): void
|
||||
{
|
||||
$query->orderByDesc(Login::select('created_at')
|
||||
->whereColumn('user_id', 'users.id')
|
||||
->latest()
|
||||
->take(1)
|
||||
);
|
||||
}
|
||||
```
|
||||
202
.claude/skills/laravel-best-practices/rules/architecture.md
Normal file
202
.claude/skills/laravel-best-practices/rules/architecture.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# Architecture Best Practices
|
||||
|
||||
## Single-Purpose Action Classes
|
||||
|
||||
Extract discrete business operations into invokable Action classes.
|
||||
|
||||
```php
|
||||
class CreateOrderAction
|
||||
{
|
||||
public function __construct(private InventoryService $inventory) {}
|
||||
|
||||
public function execute(array $data): Order
|
||||
{
|
||||
$order = Order::create($data);
|
||||
$this->inventory->reserve($order);
|
||||
|
||||
return $order;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Use Dependency Injection
|
||||
|
||||
Always use constructor injection. Avoid `app()` or `resolve()` inside classes.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function store(StoreOrderRequest $request)
|
||||
{
|
||||
$service = app(OrderService::class);
|
||||
|
||||
return $service->create($request->validated());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function __construct(private OrderService $service) {}
|
||||
|
||||
public function store(StoreOrderRequest $request)
|
||||
{
|
||||
return $this->service->create($request->validated());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Code to Interfaces
|
||||
|
||||
Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability.
|
||||
|
||||
Incorrect (concrete dependency):
|
||||
```php
|
||||
class OrderService
|
||||
{
|
||||
public function __construct(private StripeGateway $gateway) {}
|
||||
}
|
||||
```
|
||||
|
||||
Correct (interface dependency):
|
||||
```php
|
||||
interface PaymentGateway
|
||||
{
|
||||
public function charge(int $amount, string $customerId): PaymentResult;
|
||||
}
|
||||
|
||||
class OrderService
|
||||
{
|
||||
public function __construct(private PaymentGateway $gateway) {}
|
||||
}
|
||||
```
|
||||
|
||||
Bind in a service provider:
|
||||
|
||||
```php
|
||||
$this->app->bind(PaymentGateway::class, StripeGateway::class);
|
||||
```
|
||||
|
||||
## Default Sort by Descending
|
||||
|
||||
When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$posts = Post::paginate();
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$posts = Post::latest()->paginate();
|
||||
```
|
||||
|
||||
## Use Atomic Locks for Race Conditions
|
||||
|
||||
Prevent race conditions with `Cache::lock()` or `lockForUpdate()`.
|
||||
|
||||
```php
|
||||
Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
|
||||
$order->process();
|
||||
});
|
||||
|
||||
// Or at query level
|
||||
$product = Product::where('id', $id)->lockForUpdate()->first();
|
||||
```
|
||||
|
||||
## Use `mb_*` String Functions
|
||||
|
||||
When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
strlen('José'); // 5 (bytes, not characters)
|
||||
strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
mb_strlen('José'); // 4 (characters)
|
||||
mb_strtolower('MÜNCHEN'); // 'münchen'
|
||||
|
||||
// Prefer Laravel's Str helpers when available
|
||||
Str::length('José'); // 4
|
||||
Str::lower('MÜNCHEN'); // 'münchen'
|
||||
```
|
||||
|
||||
## Use `defer()` for Post-Response Work
|
||||
|
||||
For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead.
|
||||
|
||||
Incorrect (job overhead for trivial work):
|
||||
```php
|
||||
dispatch(new LogPageView($page));
|
||||
```
|
||||
|
||||
Correct (runs after response, same process):
|
||||
```php
|
||||
defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()]));
|
||||
```
|
||||
|
||||
Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work.
|
||||
|
||||
## Use `Context` for Request-Scoped Data
|
||||
|
||||
The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually.
|
||||
|
||||
```php
|
||||
// In middleware
|
||||
Context::add('tenant_id', $request->header('X-Tenant-ID'));
|
||||
|
||||
// Anywhere later — controllers, jobs, log context
|
||||
$tenantId = Context::get('tenant_id');
|
||||
```
|
||||
|
||||
Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`.
|
||||
|
||||
## Use `Concurrency::run()` for Parallel Execution
|
||||
|
||||
Run independent operations in parallel using child processes — no async libraries needed.
|
||||
|
||||
```php
|
||||
use Illuminate\Support\Facades\Concurrency;
|
||||
|
||||
[$users, $orders] = Concurrency::run([
|
||||
fn () => User::count(),
|
||||
fn () => Order::where('status', 'pending')->count(),
|
||||
]);
|
||||
```
|
||||
|
||||
Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially.
|
||||
|
||||
## Convention Over Configuration
|
||||
|
||||
Follow Laravel conventions. Don't override defaults unnecessarily.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
class Customer extends Model
|
||||
{
|
||||
protected $table = 'Customer';
|
||||
protected $primaryKey = 'customer_id';
|
||||
|
||||
public function roles(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
class Customer extends Model
|
||||
{
|
||||
public function roles(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Role::class);
|
||||
}
|
||||
}
|
||||
```
|
||||
36
.claude/skills/laravel-best-practices/rules/blade-views.md
Normal file
36
.claude/skills/laravel-best-practices/rules/blade-views.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Blade & Views Best Practices
|
||||
|
||||
## Use `$attributes->merge()` in Component Templates
|
||||
|
||||
Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly.
|
||||
|
||||
```blade
|
||||
<div {{ $attributes->merge(['class' => 'alert alert-'.$type]) }}>
|
||||
{{ $message }}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Use `@pushOnce` for Per-Component Scripts
|
||||
|
||||
If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once.
|
||||
|
||||
## Prefer Blade Components Over `@include`
|
||||
|
||||
`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots.
|
||||
|
||||
## Use View Composers for Shared View Data
|
||||
|
||||
If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it.
|
||||
|
||||
## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
|
||||
|
||||
A single view can return either the full page or just a fragment, keeping routing clean.
|
||||
|
||||
```php
|
||||
return view('dashboard', compact('users'))
|
||||
->fragmentIf($request->hasHeader('HX-Request'), 'user-list');
|
||||
```
|
||||
|
||||
## Use `@aware` for Deeply Nested Component Props
|
||||
|
||||
Avoids re-passing parent props through every level of nested components.
|
||||
70
.claude/skills/laravel-best-practices/rules/caching.md
Normal file
70
.claude/skills/laravel-best-practices/rules/caching.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Caching Best Practices
|
||||
|
||||
## Use `Cache::remember()` Instead of Manual Get/Put
|
||||
|
||||
Atomic pattern prevents race conditions and removes boilerplate.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$val = Cache::get('stats');
|
||||
if (! $val) {
|
||||
$val = $this->computeStats();
|
||||
Cache::put('stats', $val, 60);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$val = Cache::remember('stats', 60, fn () => $this->computeStats());
|
||||
```
|
||||
|
||||
## Use `Cache::flexible()` for Stale-While-Revalidate
|
||||
|
||||
On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background.
|
||||
|
||||
Incorrect: `Cache::remember('users', 300, fn () => User::all());`
|
||||
|
||||
Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function.
|
||||
|
||||
## Use `Cache::memo()` to Avoid Redundant Hits Within a Request
|
||||
|
||||
If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory.
|
||||
|
||||
`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5.
|
||||
|
||||
## Use Cache Tags to Invalidate Related Groups
|
||||
|
||||
Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`.
|
||||
|
||||
```php
|
||||
Cache::tags(['user-1'])->flush();
|
||||
```
|
||||
|
||||
## Use `Cache::add()` for Atomic Conditional Writes
|
||||
|
||||
`add()` only writes if the key does not exist — atomic, no race condition between checking and writing.
|
||||
|
||||
Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }`
|
||||
|
||||
Correct: `Cache::add('lock', true, 10);`
|
||||
|
||||
## Use `once()` for Per-Request Memoization
|
||||
|
||||
`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory.
|
||||
|
||||
```php
|
||||
public function roles(): Collection
|
||||
{
|
||||
return once(fn () => $this->loadRoles());
|
||||
}
|
||||
```
|
||||
|
||||
Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching.
|
||||
|
||||
## Configure Failover Cache Stores in Production
|
||||
|
||||
If Redis goes down, the app falls back to a secondary store automatically.
|
||||
|
||||
```php
|
||||
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
|
||||
```
|
||||
44
.claude/skills/laravel-best-practices/rules/collections.md
Normal file
44
.claude/skills/laravel-best-practices/rules/collections.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Collection Best Practices
|
||||
|
||||
## Use Higher-Order Messages for Simple Operations
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$users->each(function (User $user) {
|
||||
$user->markAsVip();
|
||||
});
|
||||
```
|
||||
|
||||
Correct: `$users->each->markAsVip();`
|
||||
|
||||
Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc.
|
||||
|
||||
## Choose `cursor()` vs. `lazy()` Correctly
|
||||
|
||||
- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk).
|
||||
- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading.
|
||||
|
||||
Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored.
|
||||
|
||||
Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work.
|
||||
|
||||
## Use `lazyById()` When Updating Records While Iterating
|
||||
|
||||
`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation.
|
||||
|
||||
## Use `toQuery()` for Bulk Operations on Collections
|
||||
|
||||
Avoids manual `whereIn` construction.
|
||||
|
||||
Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);`
|
||||
|
||||
Correct: `$users->toQuery()->update([...]);`
|
||||
|
||||
## Use `#[CollectedBy]` for Custom Collection Classes
|
||||
|
||||
More declarative than overriding `newCollection()`.
|
||||
|
||||
```php
|
||||
#[CollectedBy(UserCollection::class)]
|
||||
class User extends Model {}
|
||||
```
|
||||
73
.claude/skills/laravel-best-practices/rules/config.md
Normal file
73
.claude/skills/laravel-best-practices/rules/config.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Configuration Best Practices
|
||||
|
||||
## `env()` Only in Config Files
|
||||
|
||||
Direct `env()` calls return `null` when config is cached.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$key = env('API_KEY');
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
// config/services.php
|
||||
'key' => env('API_KEY'),
|
||||
|
||||
// Application code
|
||||
$key = config('services.key');
|
||||
```
|
||||
|
||||
## Use Encrypted Env or External Secrets
|
||||
|
||||
Never store production secrets in plain `.env` files in version control.
|
||||
|
||||
Incorrect:
|
||||
```bash
|
||||
|
||||
# .env committed to repo or shared in Slack
|
||||
|
||||
STRIPE_SECRET=sk_live_abc123
|
||||
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI
|
||||
```
|
||||
|
||||
Correct:
|
||||
```bash
|
||||
php artisan env:encrypt --env=production --readable
|
||||
php artisan env:decrypt --env=production
|
||||
```
|
||||
|
||||
For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime.
|
||||
|
||||
## Use `App::environment()` for Environment Checks
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
if (env('APP_ENV') === 'production') {
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
if (app()->isProduction()) {
|
||||
// or
|
||||
if (App::environment('production')) {
|
||||
```
|
||||
|
||||
## Use Constants and Language Files
|
||||
|
||||
Use class constants instead of hardcoded magic strings for model states, types, and statuses.
|
||||
|
||||
```php
|
||||
// Incorrect
|
||||
return $this->type === 'normal';
|
||||
|
||||
// Correct
|
||||
return $this->type === self::TYPE_NORMAL;
|
||||
```
|
||||
|
||||
If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there.
|
||||
|
||||
```php
|
||||
// Only when lang files already exist in the project
|
||||
return back()->with('message', __('app.article_added'));
|
||||
```
|
||||
192
.claude/skills/laravel-best-practices/rules/db-performance.md
Normal file
192
.claude/skills/laravel-best-practices/rules/db-performance.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Database Performance Best Practices
|
||||
|
||||
## Always Eager Load Relationships
|
||||
|
||||
Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront.
|
||||
|
||||
Incorrect (N+1 — executes 1 + N queries):
|
||||
```php
|
||||
$posts = Post::all();
|
||||
foreach ($posts as $post) {
|
||||
echo $post->author->name;
|
||||
}
|
||||
```
|
||||
|
||||
Correct (2 queries total):
|
||||
```php
|
||||
$posts = Post::with('author')->get();
|
||||
foreach ($posts as $post) {
|
||||
echo $post->author->name;
|
||||
}
|
||||
```
|
||||
|
||||
Constrain eager loads to select only needed columns (always include the foreign key):
|
||||
|
||||
```php
|
||||
$users = User::with(['posts' => function ($query) {
|
||||
$query->select('id', 'user_id', 'title')
|
||||
->where('published', true)
|
||||
->latest()
|
||||
->limit(10);
|
||||
}])->get();
|
||||
```
|
||||
|
||||
## Prevent Lazy Loading in Development
|
||||
|
||||
Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development.
|
||||
|
||||
```php
|
||||
public function boot(): void
|
||||
{
|
||||
Model::preventLazyLoading(! app()->isProduction());
|
||||
}
|
||||
```
|
||||
|
||||
Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded.
|
||||
|
||||
## Select Only Needed Columns
|
||||
|
||||
Avoid `SELECT *` — especially when tables have large text or JSON columns.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$posts = Post::with('author')->get();
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$posts = Post::select('id', 'title', 'user_id', 'created_at')
|
||||
->with(['author:id,name,avatar'])
|
||||
->get();
|
||||
```
|
||||
|
||||
When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match.
|
||||
|
||||
## Chunk Large Datasets
|
||||
|
||||
Never load thousands of records at once. Use chunking for batch processing.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$users = User::all();
|
||||
foreach ($users as $user) {
|
||||
$user->notify(new WeeklyDigest);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
User::where('subscribed', true)->chunk(200, function ($users) {
|
||||
foreach ($users as $user) {
|
||||
$user->notify(new WeeklyDigest);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change:
|
||||
|
||||
```php
|
||||
User::where('active', false)->chunkById(200, function ($users) {
|
||||
$users->each->delete();
|
||||
});
|
||||
```
|
||||
|
||||
## Add Database Indexes
|
||||
|
||||
Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained();
|
||||
$table->string('status');
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->index()->constrained();
|
||||
$table->string('status')->index();
|
||||
$table->timestamps();
|
||||
$table->index(['status', 'created_at']);
|
||||
});
|
||||
```
|
||||
|
||||
Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`).
|
||||
|
||||
## Use `withCount()` for Counting Relations
|
||||
|
||||
Never load entire collections just to count them.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$posts = Post::all();
|
||||
foreach ($posts as $post) {
|
||||
echo $post->comments->count();
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$posts = Post::withCount('comments')->get();
|
||||
foreach ($posts as $post) {
|
||||
echo $post->comments_count;
|
||||
}
|
||||
```
|
||||
|
||||
Conditional counting:
|
||||
|
||||
```php
|
||||
$posts = Post::withCount([
|
||||
'comments',
|
||||
'comments as approved_comments_count' => function ($query) {
|
||||
$query->where('approved', true);
|
||||
},
|
||||
])->get();
|
||||
```
|
||||
|
||||
## Use `cursor()` for Memory-Efficient Iteration
|
||||
|
||||
For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$users = User::where('active', true)->get();
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
foreach (User::where('active', true)->cursor() as $user) {
|
||||
ProcessUser::dispatch($user->id);
|
||||
}
|
||||
```
|
||||
|
||||
Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records.
|
||||
|
||||
## No Queries in Blade Templates
|
||||
|
||||
Never execute queries in Blade templates. Pass data from controllers.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
@foreach (User::all() as $user)
|
||||
{{ $user->profile->name }}
|
||||
@endforeach
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
// Controller
|
||||
$users = User::with('profile')->get();
|
||||
return view('users.index', compact('users'));
|
||||
```
|
||||
|
||||
```blade
|
||||
@foreach ($users as $user)
|
||||
{{ $user->profile->name }}
|
||||
@endforeach
|
||||
```
|
||||
148
.claude/skills/laravel-best-practices/rules/eloquent.md
Normal file
148
.claude/skills/laravel-best-practices/rules/eloquent.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Eloquent Best Practices
|
||||
|
||||
## Use Correct Relationship Types
|
||||
|
||||
Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints.
|
||||
|
||||
```php
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Comment::class);
|
||||
}
|
||||
|
||||
public function author(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
```
|
||||
|
||||
## Use Local Scopes for Reusable Queries
|
||||
|
||||
Extract reusable query constraints into local scopes to avoid duplication.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$active = User::where('verified', true)->whereNotNull('activated_at')->get();
|
||||
$articles = Article::whereHas('user', function ($q) {
|
||||
$q->where('verified', true)->whereNotNull('activated_at');
|
||||
})->get();
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('verified', true)->whereNotNull('activated_at');
|
||||
}
|
||||
|
||||
// Usage
|
||||
$active = User::active()->get();
|
||||
$articles = Article::whereHas('user', fn ($q) => $q->active())->get();
|
||||
```
|
||||
|
||||
## Apply Global Scopes Sparingly
|
||||
|
||||
Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy.
|
||||
|
||||
Incorrect (global scope for a conditional filter):
|
||||
```php
|
||||
class PublishedScope implements Scope
|
||||
{
|
||||
public function apply(Builder $builder, Model $model): void
|
||||
{
|
||||
$builder->where('published', true);
|
||||
}
|
||||
}
|
||||
// Now admin panels, reports, and background jobs all silently skip drafts
|
||||
```
|
||||
|
||||
Correct (local scope you opt into):
|
||||
```php
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query->where('published', true);
|
||||
}
|
||||
|
||||
Post::published()->paginate(); // Explicit
|
||||
Post::paginate(); // Admin sees all
|
||||
```
|
||||
|
||||
## Define Attribute Casts
|
||||
|
||||
Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion.
|
||||
|
||||
```php
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'metadata' => 'array',
|
||||
'total' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## Cast Date Columns Properly
|
||||
|
||||
Always cast date columns. Use Carbon instances in templates instead of formatting strings manually.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'ordered_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
```blade
|
||||
{{ $order->ordered_at->toDateString() }}
|
||||
{{ $order->ordered_at->format('m-d') }}
|
||||
```
|
||||
|
||||
## Use `whereBelongsTo()` for Relationship Queries
|
||||
|
||||
Cleaner than manually specifying foreign keys.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
Post::where('user_id', $user->id)->get();
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
Post::whereBelongsTo($user)->get();
|
||||
Post::whereBelongsTo($user, 'author')->get();
|
||||
```
|
||||
|
||||
## Avoid Hardcoded Table Names in Queries
|
||||
|
||||
Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string).
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
DB::table('users')->where('active', true)->get();
|
||||
|
||||
$query->join('companies', 'companies.id', '=', 'users.company_id');
|
||||
|
||||
DB::select('SELECT * FROM orders WHERE status = ?', ['pending']);
|
||||
```
|
||||
|
||||
Correct — reference the model's table:
|
||||
```php
|
||||
DB::table((new User)->getTable())->where('active', true)->get();
|
||||
|
||||
// Even better — use Eloquent or the query builder instead of raw SQL
|
||||
User::where('active', true)->get();
|
||||
Order::where('status', 'pending')->get();
|
||||
```
|
||||
|
||||
Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
|
||||
|
||||
**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
|
||||
@@ -0,0 +1,72 @@
|
||||
# Error Handling Best Practices
|
||||
|
||||
## Exception Reporting and Rendering
|
||||
|
||||
There are two valid approaches — choose one and apply it consistently across the project.
|
||||
|
||||
**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find:
|
||||
|
||||
```php
|
||||
class InvalidOrderException extends Exception
|
||||
{
|
||||
public function report(): void { /* custom reporting */ }
|
||||
|
||||
public function render(Request $request): Response
|
||||
{
|
||||
return response()->view('errors.invalid-order', status: 422);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture:
|
||||
|
||||
```php
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
$exceptions->report(function (InvalidOrderException $e) { /* ... */ });
|
||||
$exceptions->render(function (InvalidOrderException $e, Request $request) {
|
||||
return response()->view('errors.invalid-order', status: 422);
|
||||
});
|
||||
})
|
||||
```
|
||||
|
||||
Check the existing codebase and follow whichever pattern is already established.
|
||||
|
||||
## Use `ShouldntReport` for Exceptions That Should Never Log
|
||||
|
||||
More discoverable than listing classes in `dontReport()`.
|
||||
|
||||
```php
|
||||
class PodcastProcessingException extends Exception implements ShouldntReport {}
|
||||
```
|
||||
|
||||
## Throttle High-Volume Exceptions
|
||||
|
||||
A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type.
|
||||
|
||||
## Enable `dontReportDuplicates()`
|
||||
|
||||
Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks.
|
||||
|
||||
## Force JSON Error Rendering for API Routes
|
||||
|
||||
Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes.
|
||||
|
||||
```php
|
||||
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
|
||||
return $request->is('api/*') || $request->expectsJson();
|
||||
});
|
||||
```
|
||||
|
||||
## Add Context to Exception Classes
|
||||
|
||||
Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry.
|
||||
|
||||
```php
|
||||
class InvalidOrderException extends Exception
|
||||
{
|
||||
public function context(): array
|
||||
{
|
||||
return ['order_id' => $this->orderId];
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,48 @@
|
||||
# Events & Notifications Best Practices
|
||||
|
||||
## Rely on Event Discovery
|
||||
|
||||
Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`.
|
||||
|
||||
## Run `event:cache` in Production Deploy
|
||||
|
||||
Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`.
|
||||
|
||||
## Use `ShouldDispatchAfterCommit` Inside Transactions
|
||||
|
||||
Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet.
|
||||
|
||||
```php
|
||||
class OrderShipped implements ShouldDispatchAfterCommit {}
|
||||
```
|
||||
|
||||
## Always Queue Notifications
|
||||
|
||||
Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response.
|
||||
|
||||
```php
|
||||
class InvoicePaid extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
}
|
||||
```
|
||||
|
||||
## Use `afterCommit()` on Notifications in Transactions
|
||||
|
||||
Same race condition as events — the queued notification job may run before the transaction commits.
|
||||
|
||||
## Route Notification Channels to Dedicated Queues
|
||||
|
||||
Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues.
|
||||
|
||||
## Use On-Demand Notifications for Non-User Recipients
|
||||
|
||||
Avoid creating dummy models to send notifications to arbitrary addresses.
|
||||
|
||||
```php
|
||||
Notification::route('mail', 'admin@example.com')->notify(new SystemAlert());
|
||||
```
|
||||
|
||||
## Implement `HasLocalePreference` on Notifiable Models
|
||||
|
||||
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
|
||||
160
.claude/skills/laravel-best-practices/rules/http-client.md
Normal file
160
.claude/skills/laravel-best-practices/rules/http-client.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# HTTP Client Best Practices
|
||||
|
||||
## Always Set Explicit Timeouts
|
||||
|
||||
The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$response = Http::get('https://api.example.com/users');
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$response = Http::timeout(5)
|
||||
->connectTimeout(3)
|
||||
->get('https://api.example.com/users');
|
||||
```
|
||||
|
||||
For service-specific clients, define timeouts in a macro:
|
||||
|
||||
```php
|
||||
Http::macro('github', function () {
|
||||
return Http::baseUrl('https://api.github.com')
|
||||
->timeout(10)
|
||||
->connectTimeout(3)
|
||||
->withToken(config('services.github.token'));
|
||||
});
|
||||
|
||||
$response = Http::github()->get('/repos/laravel/framework');
|
||||
```
|
||||
|
||||
## Use Retry with Backoff for External APIs
|
||||
|
||||
External APIs have transient failures. Use `retry()` with increasing delays.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$response = Http::post('https://api.stripe.com/v1/charges', $data);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new PaymentFailedException('Charge failed');
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$response = Http::retry([100, 500, 1000])
|
||||
->timeout(10)
|
||||
->post('https://api.stripe.com/v1/charges', $data);
|
||||
```
|
||||
|
||||
Only retry on specific errors:
|
||||
|
||||
```php
|
||||
$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) {
|
||||
return $exception instanceof ConnectionException
|
||||
|| ($exception instanceof RequestException && $exception->response->serverError());
|
||||
})->post('https://api.example.com/data');
|
||||
```
|
||||
|
||||
## Handle Errors Explicitly
|
||||
|
||||
The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$response = Http::get('https://api.example.com/users/1');
|
||||
$user = $response->json(); // Could be an error body
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$response = Http::timeout(5)
|
||||
->get('https://api.example.com/users/1')
|
||||
->throw();
|
||||
|
||||
$user = $response->json();
|
||||
```
|
||||
|
||||
For graceful degradation:
|
||||
|
||||
```php
|
||||
$response = Http::get('https://api.example.com/users/1');
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
if ($response->notFound()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$response->throw();
|
||||
```
|
||||
|
||||
## Use Request Pooling for Concurrent Requests
|
||||
|
||||
When making multiple independent API calls, use `Http::pool()` instead of sequential calls.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$users = Http::get('https://api.example.com/users')->json();
|
||||
$posts = Http::get('https://api.example.com/posts')->json();
|
||||
$comments = Http::get('https://api.example.com/comments')->json();
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
use Illuminate\Http\Client\Pool;
|
||||
|
||||
$responses = Http::pool(fn (Pool $pool) => [
|
||||
$pool->as('users')->get('https://api.example.com/users'),
|
||||
$pool->as('posts')->get('https://api.example.com/posts'),
|
||||
$pool->as('comments')->get('https://api.example.com/comments'),
|
||||
]);
|
||||
|
||||
$users = $responses['users']->json();
|
||||
$posts = $responses['posts']->json();
|
||||
```
|
||||
|
||||
## Fake HTTP Calls in Tests
|
||||
|
||||
Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
it('syncs user from API', function () {
|
||||
$service = new UserSyncService;
|
||||
$service->sync(1); // Hits the real API
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
it('syncs user from API', function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
Http::fake([
|
||||
'api.example.com/users/1' => Http::response([
|
||||
'name' => 'John Doe',
|
||||
'email' => 'john@example.com',
|
||||
]),
|
||||
]);
|
||||
|
||||
$service = new UserSyncService;
|
||||
$service->sync(1);
|
||||
|
||||
Http::assertSent(function (Request $request) {
|
||||
return $request->url() === 'https://api.example.com/users/1';
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Test failure scenarios too:
|
||||
|
||||
```php
|
||||
Http::fake([
|
||||
'api.example.com/*' => Http::failedConnection(),
|
||||
]);
|
||||
```
|
||||
27
.claude/skills/laravel-best-practices/rules/mail.md
Normal file
27
.claude/skills/laravel-best-practices/rules/mail.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Mail Best Practices
|
||||
|
||||
## Implement `ShouldQueue` on the Mailable Class
|
||||
|
||||
Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it.
|
||||
|
||||
## Use `afterCommit()` on Mailables Inside Transactions
|
||||
|
||||
A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor.
|
||||
|
||||
## Use `assertQueued()` Not `assertSent()` for Queued Mailables
|
||||
|
||||
`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence.
|
||||
|
||||
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
|
||||
|
||||
Correct: `Mail::assertQueued(OrderShipped::class);`
|
||||
|
||||
## Use Markdown Mailables for Transactional Emails
|
||||
|
||||
Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag.
|
||||
|
||||
## Separate Content Tests from Sending Tests
|
||||
|
||||
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
|
||||
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
|
||||
Don't mix them — it conflates concerns and makes tests brittle.
|
||||
121
.claude/skills/laravel-best-practices/rules/migrations.md
Normal file
121
.claude/skills/laravel-best-practices/rules/migrations.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Migration Best Practices
|
||||
|
||||
## Generate Migrations with Artisan
|
||||
|
||||
Always use `php artisan make:migration` for consistent naming and timestamps.
|
||||
|
||||
Incorrect (manually created file):
|
||||
```php
|
||||
// database/migrations/posts_migration.php ← wrong naming, no timestamp
|
||||
```
|
||||
|
||||
Correct (Artisan-generated):
|
||||
```bash
|
||||
php artisan make:migration create_posts_table
|
||||
php artisan make:migration add_slug_to_posts_table
|
||||
```
|
||||
|
||||
## Use `constrained()` for Foreign Keys
|
||||
|
||||
Automatic naming and referential integrity.
|
||||
|
||||
```php
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
// Non-standard names
|
||||
$table->foreignId('author_id')->constrained('users');
|
||||
```
|
||||
|
||||
## Never Modify Deployed Migrations
|
||||
|
||||
Once a migration has run in production, treat it as immutable. Create a new migration to change the table.
|
||||
|
||||
Incorrect (editing a deployed migration):
|
||||
```php
|
||||
// 2024_01_01_create_posts_table.php — already in production
|
||||
$table->string('slug')->unique(); // ← added after deployment
|
||||
```
|
||||
|
||||
Correct (new migration to alter):
|
||||
```php
|
||||
// 2024_03_15_add_slug_to_posts_table.php
|
||||
Schema::table('posts', function (Blueprint $table) {
|
||||
$table->string('slug')->unique()->after('title');
|
||||
});
|
||||
```
|
||||
|
||||
## Add Indexes in the Migration
|
||||
|
||||
Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained();
|
||||
$table->string('status');
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->index();
|
||||
$table->string('status')->index();
|
||||
$table->timestamp('shipped_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
|
||||
## Mirror Defaults in Model `$attributes`
|
||||
|
||||
When a column has a database default, mirror it in the model so new instances have correct values before saving.
|
||||
|
||||
```php
|
||||
// Migration
|
||||
$table->string('status')->default('pending');
|
||||
|
||||
// Model
|
||||
protected $attributes = [
|
||||
'status' => 'pending',
|
||||
];
|
||||
```
|
||||
|
||||
## Write Reversible `down()` Methods by Default
|
||||
|
||||
Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments.
|
||||
|
||||
```php
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('posts', function (Blueprint $table) {
|
||||
$table->dropColumn('slug');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported.
|
||||
|
||||
## Keep Migrations Focused
|
||||
|
||||
One concern per migration. Never mix DDL (schema changes) and DML (data manipulation).
|
||||
|
||||
Incorrect (partial failure creates unrecoverable state):
|
||||
```php
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('settings', function (Blueprint $table) { ... });
|
||||
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
||||
}
|
||||
```
|
||||
|
||||
Correct (separate migrations):
|
||||
```php
|
||||
// Migration 1: create_settings_table
|
||||
Schema::create('settings', function (Blueprint $table) { ... });
|
||||
|
||||
// Migration 2: seed_default_settings
|
||||
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
||||
```
|
||||
146
.claude/skills/laravel-best-practices/rules/queue-jobs.md
Normal file
146
.claude/skills/laravel-best-practices/rules/queue-jobs.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Queue & Job Best Practices
|
||||
|
||||
## Set `retry_after` Greater Than `timeout`
|
||||
|
||||
If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution.
|
||||
|
||||
Incorrect (`retry_after` ≤ `timeout`):
|
||||
```php
|
||||
class ProcessReport implements ShouldQueue
|
||||
{
|
||||
public $timeout = 120;
|
||||
}
|
||||
|
||||
// config/queue.php — retry_after: 90 ← job retried while still running!
|
||||
```
|
||||
|
||||
Correct (`retry_after` > `timeout`):
|
||||
```php
|
||||
class ProcessReport implements ShouldQueue
|
||||
{
|
||||
public $timeout = 120;
|
||||
}
|
||||
|
||||
// config/queue.php — retry_after: 180 ← safely longer than any job timeout
|
||||
```
|
||||
|
||||
## Use Exponential Backoff
|
||||
|
||||
Use progressively longer delays between retries to avoid hammering failing services.
|
||||
|
||||
Incorrect (fixed retry interval):
|
||||
```php
|
||||
class SyncWithStripe implements ShouldQueue
|
||||
{
|
||||
public $tries = 3;
|
||||
// Default: retries immediately, overwhelming the API
|
||||
}
|
||||
```
|
||||
|
||||
Correct (exponential backoff):
|
||||
```php
|
||||
class SyncWithStripe implements ShouldQueue
|
||||
{
|
||||
public $tries = 3;
|
||||
public $backoff = [1, 5, 10];
|
||||
}
|
||||
```
|
||||
|
||||
## Implement `ShouldBeUnique`
|
||||
|
||||
Prevent duplicate job processing.
|
||||
|
||||
```php
|
||||
class GenerateInvoice implements ShouldQueue, ShouldBeUnique
|
||||
{
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return $this->order->id;
|
||||
}
|
||||
|
||||
public $uniqueFor = 3600;
|
||||
}
|
||||
```
|
||||
|
||||
## Always Implement `failed()`
|
||||
|
||||
Handle errors explicitly — don't rely on silent failure.
|
||||
|
||||
```php
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
$this->podcast->update(['status' => 'failed']);
|
||||
Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]);
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limit External API Calls in Jobs
|
||||
|
||||
Use `RateLimited` middleware to throttle jobs calling third-party APIs.
|
||||
|
||||
```php
|
||||
public function middleware(): array
|
||||
{
|
||||
return [new RateLimited('external-api')];
|
||||
}
|
||||
```
|
||||
|
||||
## Batch Related Jobs
|
||||
|
||||
Use `Bus::batch()` when jobs should succeed or fail together.
|
||||
|
||||
```php
|
||||
Bus::batch([
|
||||
new ImportCsvChunk($chunk1),
|
||||
new ImportCsvChunk($chunk2),
|
||||
])
|
||||
->then(fn (Batch $batch) => Notification::send($user, new ImportComplete))
|
||||
->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed'))
|
||||
->dispatch();
|
||||
```
|
||||
|
||||
## `retryUntil()` Needs `$tries = 0`
|
||||
|
||||
When using time-based retry limits, set `$tries = 0` to avoid premature failure.
|
||||
|
||||
```php
|
||||
public $tries = 0;
|
||||
|
||||
public function retryUntil(): DateTime
|
||||
{
|
||||
return now()->addHours(4);
|
||||
}
|
||||
```
|
||||
|
||||
## Use `WithoutOverlapping::untilProcessing()`
|
||||
|
||||
Prevents concurrent execution while allowing new instances to queue.
|
||||
|
||||
```php
|
||||
public function middleware(): array
|
||||
{
|
||||
return [new WithoutOverlapping($this->product->id)->untilProcessing()];
|
||||
}
|
||||
```
|
||||
|
||||
Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts.
|
||||
|
||||
## Use Horizon for Complex Queue Scenarios
|
||||
|
||||
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
|
||||
|
||||
```php
|
||||
// config/horizon.php
|
||||
'environments' => [
|
||||
'production' => [
|
||||
'supervisor-1' => [
|
||||
'connection' => 'redis',
|
||||
'queue' => ['high', 'default', 'low'],
|
||||
'balance' => 'auto',
|
||||
'minProcesses' => 1,
|
||||
'maxProcesses' => 10,
|
||||
'tries' => 3,
|
||||
],
|
||||
],
|
||||
],
|
||||
```
|
||||
98
.claude/skills/laravel-best-practices/rules/routing.md
Normal file
98
.claude/skills/laravel-best-practices/rules/routing.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Routing & Controllers Best Practices
|
||||
|
||||
## Use Implicit Route Model Binding
|
||||
|
||||
Let Laravel resolve models automatically from route parameters.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function show(int $id)
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function show(Post $post)
|
||||
{
|
||||
return view('posts.show', ['post' => $post]);
|
||||
}
|
||||
```
|
||||
|
||||
## Use Scoped Bindings for Nested Resources
|
||||
|
||||
Enforce parent-child relationships automatically.
|
||||
|
||||
```php
|
||||
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
|
||||
// $post is automatically scoped to $user
|
||||
})->scopeBindings();
|
||||
```
|
||||
|
||||
## Use Resource Controllers
|
||||
|
||||
Use `Route::resource()` or `apiResource()` for RESTful endpoints.
|
||||
|
||||
```php
|
||||
Route::resource('posts', PostController::class);
|
||||
Route::apiResource('api/posts', Api\PostController::class);
|
||||
```
|
||||
|
||||
## Keep Controllers Thin
|
||||
|
||||
Aim for under 10 lines per method. Extract business logic to action or service classes.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([...]);
|
||||
if ($request->hasFile('image')) {
|
||||
$request->file('image')->move(public_path('images'));
|
||||
}
|
||||
$post = Post::create($validated);
|
||||
$post->tags()->sync($validated['tags']);
|
||||
event(new PostCreated($post));
|
||||
return redirect()->route('posts.show', $post);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function store(StorePostRequest $request, CreatePostAction $create)
|
||||
{
|
||||
$post = $create->execute($request->validated());
|
||||
|
||||
return redirect()->route('posts.show', $post);
|
||||
}
|
||||
```
|
||||
|
||||
## Type-Hint Form Requests
|
||||
|
||||
Type-hinting Form Requests triggers automatic validation and authorization before the method executes.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => ['required', 'max:255'],
|
||||
'body' => ['required'],
|
||||
]);
|
||||
|
||||
Post::create($validated);
|
||||
|
||||
return redirect()->route('posts.index');
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function store(StorePostRequest $request): RedirectResponse
|
||||
{
|
||||
Post::create($request->validated());
|
||||
|
||||
return redirect()->route('posts.index');
|
||||
}
|
||||
```
|
||||
39
.claude/skills/laravel-best-practices/rules/scheduling.md
Normal file
39
.claude/skills/laravel-best-practices/rules/scheduling.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Task Scheduling Best Practices
|
||||
|
||||
## Use `withoutOverlapping()` on Variable-Duration Tasks
|
||||
|
||||
Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion.
|
||||
|
||||
## Use `onOneServer()` on Multi-Server Deployments
|
||||
|
||||
Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached).
|
||||
|
||||
## Use `runInBackground()` for Concurrent Long Tasks
|
||||
|
||||
By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes.
|
||||
|
||||
## Use `environments()` to Restrict Tasks
|
||||
|
||||
Prevent accidental execution of production-only tasks (billing, reporting) on staging.
|
||||
|
||||
```php
|
||||
Schedule::command('billing:charge')->monthly()->environments(['production']);
|
||||
```
|
||||
|
||||
## Use `takeUntilTimeout()` for Time-Bounded Processing
|
||||
|
||||
A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time.
|
||||
|
||||
## Use Schedule Groups for Shared Configuration
|
||||
|
||||
Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks.
|
||||
|
||||
```php
|
||||
Schedule::daily()
|
||||
->onOneServer()
|
||||
->timezone('America/New_York')
|
||||
->group(function () {
|
||||
Schedule::command('emails:send --force');
|
||||
Schedule::command('emails:prune');
|
||||
});
|
||||
```
|
||||
198
.claude/skills/laravel-best-practices/rules/security.md
Normal file
198
.claude/skills/laravel-best-practices/rules/security.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# Security Best Practices
|
||||
|
||||
## Mass Assignment Protection
|
||||
|
||||
Every model must define `$fillable` (whitelist) or `$guarded` (blacklist).
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
class User extends Model
|
||||
{
|
||||
protected $guarded = []; // All fields are mass assignable
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
class User extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Never use `$guarded = []` on models that accept user input.
|
||||
|
||||
## Authorize Every Action
|
||||
|
||||
Use policies or gates in controllers. Never skip authorization.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function update(Request $request, Post $post)
|
||||
{
|
||||
$post->update($request->validated());
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function update(UpdatePostRequest $request, Post $post)
|
||||
{
|
||||
Gate::authorize('update', $post);
|
||||
|
||||
$post->update($request->validated());
|
||||
}
|
||||
```
|
||||
|
||||
Or via Form Request:
|
||||
|
||||
```php
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('update', $this->route('post'));
|
||||
}
|
||||
```
|
||||
|
||||
## Prevent SQL Injection
|
||||
|
||||
Always use parameter binding. Never interpolate user input into queries.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
DB::select("SELECT * FROM users WHERE name = '{$request->name}'");
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
User::where('name', $request->name)->get();
|
||||
|
||||
// Raw expressions with bindings
|
||||
User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get();
|
||||
```
|
||||
|
||||
## Escape Output to Prevent XSS
|
||||
|
||||
Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
{!! $user->bio !!}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```blade
|
||||
{{ $user->bio }}
|
||||
```
|
||||
|
||||
## CSRF Protection
|
||||
|
||||
Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
<form method="POST" action="/posts">
|
||||
<input type="text" name="title">
|
||||
</form>
|
||||
```
|
||||
|
||||
Correct:
|
||||
```blade
|
||||
<form method="POST" action="/posts">
|
||||
@csrf
|
||||
<input type="text" name="title">
|
||||
</form>
|
||||
```
|
||||
|
||||
## Rate Limit Auth and API Routes
|
||||
|
||||
Apply `throttle` middleware to authentication and API routes.
|
||||
|
||||
```php
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->ip());
|
||||
});
|
||||
|
||||
Route::post('/login', LoginController::class)->middleware('throttle:login');
|
||||
```
|
||||
|
||||
## Validate File Uploads
|
||||
|
||||
Validate MIME type, extension, and size. Never trust client-provided filenames.
|
||||
|
||||
```php
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Store with generated filenames:
|
||||
|
||||
```php
|
||||
$path = $request->file('avatar')->store('avatars', 'public');
|
||||
```
|
||||
|
||||
## Keep Secrets Out of Code
|
||||
|
||||
Never commit `.env`. Access secrets via `config()` only.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$key = env('API_KEY');
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
// config/services.php
|
||||
'api_key' => env('API_KEY'),
|
||||
|
||||
// In application code
|
||||
$key = config('services.api_key');
|
||||
```
|
||||
|
||||
## Audit Dependencies
|
||||
|
||||
Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment.
|
||||
|
||||
```bash
|
||||
composer audit
|
||||
```
|
||||
|
||||
## Encrypt Sensitive Database Fields
|
||||
|
||||
Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
class Integration extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'api_key' => 'string',
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
class Integration extends Model
|
||||
{
|
||||
protected $hidden = ['api_key', 'api_secret'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'api_key' => 'encrypted',
|
||||
'api_secret' => 'encrypted',
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
BIN
.claude/skills/laravel-best-practices/rules/style.md
Normal file
BIN
.claude/skills/laravel-best-practices/rules/style.md
Normal file
Binary file not shown.
43
.claude/skills/laravel-best-practices/rules/testing.md
Normal file
43
.claude/skills/laravel-best-practices/rules/testing.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Testing Best Practices
|
||||
|
||||
## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
|
||||
|
||||
`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites.
|
||||
|
||||
## Use Model Assertions Over Raw Database Assertions
|
||||
|
||||
Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);`
|
||||
|
||||
Correct: `$this->assertModelExists($user);`
|
||||
|
||||
More expressive, type-safe, and fails with clearer messages.
|
||||
|
||||
## Use Factory States and Sequences
|
||||
|
||||
Named states make tests self-documenting. Sequences eliminate repetitive setup.
|
||||
|
||||
Incorrect: `User::factory()->create(['email_verified_at' => null]);`
|
||||
|
||||
Correct: `User::factory()->unverified()->create();`
|
||||
|
||||
## Use `Exceptions::fake()` to Assert Exception Reporting
|
||||
|
||||
Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally.
|
||||
|
||||
## Call `Event::fake()` After Factory Setup
|
||||
|
||||
Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models.
|
||||
|
||||
Incorrect: `Event::fake(); $user = User::factory()->create();`
|
||||
|
||||
Correct: `$user = User::factory()->create(); Event::fake();`
|
||||
|
||||
## Use `recycle()` to Share Relationship Instances Across Factories
|
||||
|
||||
Without `recycle()`, nested factories create separate instances of the same conceptual entity.
|
||||
|
||||
```php
|
||||
Ticket::factory()
|
||||
->recycle(Airline::factory()->create())
|
||||
->create();
|
||||
```
|
||||
75
.claude/skills/laravel-best-practices/rules/validation.md
Normal file
75
.claude/skills/laravel-best-practices/rules/validation.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Validation & Forms Best Practices
|
||||
|
||||
## Use Form Request Classes
|
||||
|
||||
Extract validation from controllers into dedicated Form Request classes.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'title' => 'required|max:255',
|
||||
'body' => 'required',
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function store(StorePostRequest $request)
|
||||
{
|
||||
Post::create($request->validated());
|
||||
}
|
||||
```
|
||||
|
||||
## Array vs. String Notation for Rules
|
||||
|
||||
Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses.
|
||||
|
||||
```php
|
||||
// Preferred for new code
|
||||
'email' => ['required', 'email', Rule::unique('users')],
|
||||
|
||||
// Follow existing convention if the project uses string notation
|
||||
'email' => 'required|email|unique:users',
|
||||
```
|
||||
|
||||
## Always Use `validated()`
|
||||
|
||||
Get only validated data. Never use `$request->all()` for mass operations.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
Post::create($request->all());
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
Post::create($request->validated());
|
||||
```
|
||||
|
||||
## Use `Rule::when()` for Conditional Validation
|
||||
|
||||
```php
|
||||
'company_name' => [
|
||||
Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']),
|
||||
],
|
||||
```
|
||||
|
||||
## Use the `after()` Method for Custom Validation
|
||||
|
||||
Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields.
|
||||
|
||||
```php
|
||||
public function after(): array
|
||||
{
|
||||
return [
|
||||
function (Validator $validator) {
|
||||
if ($this->quantity > Product::find($this->product_id)?->stock) {
|
||||
$validator->errors()->add('quantity', 'Not enough stock.');
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
156
.claude/skills/livewire-development/SKILL.md
Normal file
156
.claude/skills/livewire-development/SKILL.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: livewire-development
|
||||
description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Livewire Development
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Livewire 4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating Components
|
||||
|
||||
```bash
|
||||
|
||||
# Single-file component (default in v4)
|
||||
|
||||
php artisan make:livewire create-post
|
||||
|
||||
# Multi-file component
|
||||
|
||||
php artisan make:livewire create-post --mfc
|
||||
|
||||
# Class-based component (v3 style)
|
||||
|
||||
php artisan make:livewire create-post --class
|
||||
|
||||
# With namespace
|
||||
|
||||
php artisan make:livewire Posts/CreatePost
|
||||
```
|
||||
|
||||
### Converting Between Formats
|
||||
|
||||
Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats.
|
||||
|
||||
### Choosing a Component Format
|
||||
|
||||
Before creating a component, check `config/livewire.php` for directory overrides, which change where files are stored. Then, look at existing files in those directories (defaulting to `app/Livewire/` and `resources/views/livewire/`) to match the established convention.
|
||||
|
||||
### Component Format Reference
|
||||
|
||||
| Format | Flag | Class Path | View Path |
|
||||
|--------|------|------------|-----------|
|
||||
| Single-file (SFC) | default | — | `resources/views/livewire/create-post.blade.php` (PHP + Blade in one file) |
|
||||
| Multi-file (MFC) | `--mfc` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` |
|
||||
| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` |
|
||||
| View-based | ⚡ prefix | — | `resources/views/livewire/create-post.blade.php` (Blade-only with functional state) |
|
||||
|
||||
Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates files at `app/Livewire/Posts/CreatePost.php` and `resources/views/livewire/posts/create-post.blade.php`.
|
||||
|
||||
### Single-File Component Example
|
||||
|
||||
<!-- Single-File Component Example -->
|
||||
```php
|
||||
<?php
|
||||
use Livewire\Component;
|
||||
|
||||
new class extends Component {
|
||||
public int $count = 0;
|
||||
|
||||
public function increment(): void
|
||||
{
|
||||
$this->count++;
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
<div>
|
||||
<button wire:click="increment">Count: @{{ $count }}</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Livewire 4 Specifics
|
||||
|
||||
### Key Changes From Livewire 3
|
||||
|
||||
These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions.
|
||||
|
||||
- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`.
|
||||
- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`.
|
||||
- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed).
|
||||
- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`.
|
||||
|
||||
### New Features
|
||||
|
||||
- Component formats: single-file (SFC), multi-file (MFC), view-based components.
|
||||
- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution.
|
||||
- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading.
|
||||
|
||||
| Feature | Usage | Purpose |
|
||||
|---------|-------|---------|
|
||||
| Islands | `@island(name: 'stats')` | Isolated update regions |
|
||||
| Async | `wire:click.async` or `#[Async]` | Non-blocking actions |
|
||||
| Deferred | `defer` attribute | Load after page render |
|
||||
| Bundled | `lazy.bundle` | Load multiple together |
|
||||
|
||||
### New Directives
|
||||
|
||||
- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use.
|
||||
- `data-loading` attribute automatically added to elements triggering network requests.
|
||||
|
||||
| Directive | Purpose |
|
||||
|-----------|---------|
|
||||
| `wire:sort` | Drag-and-drop sorting |
|
||||
| `wire:intersect` | Viewport intersection detection |
|
||||
| `wire:ref` | Element references for JS |
|
||||
| `.renderless` | Component without rendering |
|
||||
| `.preserve-scroll` | Preserve scroll position |
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Always use `wire:key` in loops
|
||||
- Use `wire:loading` for loading states
|
||||
- Use `wire:model.live` for instant updates (default is debounced)
|
||||
- Validate and authorize in actions (treat like HTTP requests)
|
||||
|
||||
## Configuration
|
||||
|
||||
- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`.
|
||||
|
||||
## Alpine & JavaScript
|
||||
|
||||
- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available.
|
||||
- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance.
|
||||
|
||||
For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md).
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Testing Example -->
|
||||
```php
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
->assertSet('count', 1);
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. Browser console: Check for JS errors
|
||||
2. Network tab: Verify Livewire requests return 200
|
||||
3. Ensure `wire:key` on all `@foreach` loops
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Missing `wire:key` in loops → unexpected re-rendering
|
||||
- Expecting `wire:model` real-time → use `wire:model.live`
|
||||
- Unclosed component tags → syntax errors in v4
|
||||
- Using deprecated config keys or JS hooks
|
||||
- Including Alpine.js separately (already bundled in Livewire 4)
|
||||
@@ -0,0 +1,39 @@
|
||||
# Livewire 4 JavaScript Integration
|
||||
|
||||
## Interceptor System (v4)
|
||||
|
||||
### Intercept Messages
|
||||
|
||||
```js
|
||||
Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => {
|
||||
onFinish(() => { /* After response, before processing */ });
|
||||
onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ });
|
||||
onError(() => { /* Server errors */ });
|
||||
});
|
||||
```
|
||||
|
||||
### Intercept Requests
|
||||
|
||||
```js
|
||||
Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => {
|
||||
onResponse(({ response }) => { /* When received */ });
|
||||
onSuccess(({ response, responseJson }) => { /* Success */ });
|
||||
onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ });
|
||||
onFailure(({ error }) => { /* Network failures */ });
|
||||
});
|
||||
```
|
||||
|
||||
### Component-Scoped Interceptors
|
||||
|
||||
```blade
|
||||
<script>
|
||||
this.$intercept('save', ({ component, onSuccess }) => {
|
||||
onSuccess(() => console.log('Saved!'));
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Magic Properties
|
||||
|
||||
- `$errors` - Access validation errors from JavaScript
|
||||
- `$intercept` - Component-scoped interceptors
|
||||
157
.claude/skills/pest-testing/SKILL.md
Normal file
157
.claude/skills/pest-testing/SKILL.md
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
name: pest-testing
|
||||
description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Pest Testing 4
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Pest 4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating Tests
|
||||
|
||||
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
||||
|
||||
### Test Organization
|
||||
|
||||
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||
- Browser tests: `tests/Browser/` directory.
|
||||
- Do NOT remove tests without approval - these are core application code.
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
<!-- Basic Pest Test Example -->
|
||||
```php
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
|
||||
- Run all tests: `php artisan test --compact`.
|
||||
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
|
||||
## Assertions
|
||||
|
||||
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
|
||||
|
||||
<!-- Pest Response Assertion -->
|
||||
```php
|
||||
it('returns all', function () {
|
||||
$this->postJson('/api/docs', [])->assertSuccessful();
|
||||
});
|
||||
```
|
||||
|
||||
| Use | Instead of |
|
||||
|-----|------------|
|
||||
| `assertSuccessful()` | `assertStatus(200)` |
|
||||
| `assertNotFound()` | `assertStatus(404)` |
|
||||
| `assertForbidden()` | `assertStatus(403)` |
|
||||
|
||||
## Mocking
|
||||
|
||||
Import mock function before use: `use function Pest\Laravel\mock;`
|
||||
|
||||
## Datasets
|
||||
|
||||
Use datasets for repetitive tests (validation rules, etc.):
|
||||
|
||||
<!-- Pest Dataset Example -->
|
||||
```php
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
```
|
||||
|
||||
## Pest 4 Features
|
||||
|
||||
| Feature | Purpose |
|
||||
|---------|---------|
|
||||
| Browser Testing | Full integration tests in real browsers |
|
||||
| Smoke Testing | Validate multiple pages quickly |
|
||||
| Visual Regression | Compare screenshots for visual changes |
|
||||
| Test Sharding | Parallel CI runs |
|
||||
| Architecture Testing | Enforce code conventions |
|
||||
|
||||
### Browser Test Example
|
||||
|
||||
Browser tests run in real browsers for full integration testing:
|
||||
|
||||
- Browser tests live in `tests/Browser/`.
|
||||
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
|
||||
- Use `RefreshDatabase` for clean state per test.
|
||||
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
|
||||
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
|
||||
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging.
|
||||
|
||||
<!-- Pest Browser Test Example -->
|
||||
```php
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in');
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavaScriptErrors()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!');
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
```
|
||||
|
||||
### Smoke Testing
|
||||
|
||||
Quickly validate multiple pages have no JavaScript errors:
|
||||
|
||||
<!-- Pest Smoke Testing Example -->
|
||||
```php
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
|
||||
```
|
||||
|
||||
### Visual Regression Testing
|
||||
|
||||
Capture and compare screenshots to detect visual changes.
|
||||
|
||||
### Test Sharding
|
||||
|
||||
Split tests across parallel processes for faster CI runs.
|
||||
|
||||
### Architecture Testing
|
||||
|
||||
Pest 4 includes architecture testing (from Pest 3):
|
||||
|
||||
<!-- Architecture Test Example -->
|
||||
```php
|
||||
arch('controllers')
|
||||
->expect('App\Http\Controllers')
|
||||
->toExtendNothing()
|
||||
->toHaveSuffix('Controller');
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Not importing `use function Pest\Laravel\mock;` before using mock
|
||||
- Using `assertStatus(200)` instead of `assertSuccessful()`
|
||||
- Forgetting datasets for repetitive validation tests
|
||||
- Deleting tests without approval
|
||||
- Forgetting `assertNoJavaScriptErrors()` in browser tests
|
||||
119
.claude/skills/tailwindcss-development/SKILL.md
Normal file
119
.claude/skills/tailwindcss-development/SKILL.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: tailwindcss-development
|
||||
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Tailwind CSS Development
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||
|
||||
## Tailwind CSS v4 Specifics
|
||||
|
||||
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
|
||||
### CSS-First Configuration
|
||||
|
||||
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
||||
|
||||
<!-- CSS-First Config -->
|
||||
```css
|
||||
@theme {
|
||||
--color-brand: oklch(0.72 0.11 178);
|
||||
}
|
||||
```
|
||||
|
||||
### Import Syntax
|
||||
|
||||
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
||||
|
||||
<!-- v4 Import Syntax -->
|
||||
```diff
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
```
|
||||
|
||||
### Replaced Utilities
|
||||
|
||||
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------|-------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
## Spacing
|
||||
|
||||
Use `gap` utilities instead of margins for spacing between siblings:
|
||||
|
||||
<!-- Gap Utilities -->
|
||||
```html
|
||||
<div class="flex gap-8">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Dark Mode
|
||||
|
||||
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
||||
|
||||
<!-- Dark Mode -->
|
||||
```html
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
Content adapts to color scheme
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Flexbox Layout
|
||||
|
||||
<!-- Flexbox Layout -->
|
||||
```html
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>Left content</div>
|
||||
<div>Right content</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Grid Layout
|
||||
|
||||
<!-- Grid Layout -->
|
||||
```html
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>Card 1</div>
|
||||
<div>Card 2</div>
|
||||
<div>Card 3</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
||||
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||
- Using margins for spacing between siblings instead of gap utilities
|
||||
- Forgetting to add dark mode variants when the project uses dark mode
|
||||
11
.mcp.json
Normal file
11
.mcp.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "php",
|
||||
"args": [
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
# Local overrides (gitignored — personal only)
|
||||
|
||||
## Local environment
|
||||
|
||||
- PhpStorm with SFTP sync to Proxmox LXC (CT 150, 192.168.1.150)
|
||||
- MySQL on IONOS VPS: 192.168.x.x (update with actual)
|
||||
- Local dev URL: http://fuel-alert.test (Valet) or http://localhost:8000
|
||||
|
||||
## Test credentials (local only)
|
||||
|
||||
- Stripe test publishable key: pk_test_...
|
||||
- Fuel Finder sandbox credentials (if available): see 1Password
|
||||
- Vonage test account: see 1Password
|
||||
- OneSignal test app: see 1Password
|
||||
|
||||
## Deployment
|
||||
|
||||
- Production: IONOS VPS behind Traefik (same setup as uovidiu.com portfolio)
|
||||
- Deploy: git push → SSH → composer install --no-dev → php artisan migrate --force → php artisan queue:restart
|
||||
- Redis: Docker container on IONOS VPS
|
||||
|
||||
## Useful local commands
|
||||
|
||||
```bash
|
||||
# Manually trigger fuel price poll
|
||||
php artisan app:poll-fuel-prices
|
||||
|
||||
# Run scoring for a specific user
|
||||
php artisan app:score-user {user_id}
|
||||
|
||||
# Clear scored results cache
|
||||
php artisan cache:forget scoring_results
|
||||
```
|
||||
184
CLAUDE.md
184
CLAUDE.md
@@ -36,3 +36,187 @@ npm run dev # Vite asset watcher
|
||||
@.claude/rules/api-data.md
|
||||
@.claude/rules/testing.md
|
||||
@.claude/rules/code-style.md
|
||||
|
||||
===
|
||||
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4
|
||||
- laravel/fortify (FORTIFY) - v1
|
||||
- laravel/framework (LARAVEL) - v13
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- livewire/flux (FLUXUI_FREE) - v2
|
||||
- livewire/livewire (LIVEWIRE) - v4
|
||||
- laravel/boost (BOOST) - v2
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pail (PAIL) - v1
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- pestphp/pest (PEST) - v4
|
||||
- phpunit/phpunit (PHPUNIT) - v12
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
|
||||
## Skills Activation
|
||||
|
||||
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||
|
||||
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
|
||||
- `fluxui-development` — Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling.
|
||||
- `livewire-development` — Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire.
|
||||
- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
|
||||
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
|
||||
|
||||
## Conventions
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
## Replies
|
||||
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
# Laravel Boost
|
||||
|
||||
## Tools
|
||||
|
||||
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
|
||||
- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
|
||||
- Use `database-schema` to inspect table structure before writing migrations or models.
|
||||
- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
|
||||
- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
|
||||
|
||||
## Searching Documentation (IMPORTANT)
|
||||
|
||||
- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
|
||||
- Pass a `packages` array to scope results when you know which packages are relevant.
|
||||
- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
|
||||
- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Search Syntax
|
||||
|
||||
1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
|
||||
2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
|
||||
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
|
||||
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
|
||||
|
||||
## Artisan
|
||||
|
||||
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
||||
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
||||
- To check environment variables, read the `.env` file directly.
|
||||
|
||||
## Tinker
|
||||
|
||||
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
|
||||
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
|
||||
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
|
||||
|
||||
=== php rules ===
|
||||
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
|
||||
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
|
||||
- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
|
||||
- Use array shape type definitions in PHPDoc blocks.
|
||||
|
||||
=== herd rules ===
|
||||
|
||||
# Laravel Herd
|
||||
|
||||
- The application is served by Laravel Herd at `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs. Never run commands to serve the site. It is always available.
|
||||
- Use the `herd` CLI to manage services, PHP versions, and sites (e.g. `herd sites`, `herd services:start <service>`, `herd php:list`). Run `herd list` to discover all available commands.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
|
||||
|
||||
## APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
# Livewire
|
||||
|
||||
- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript.
|
||||
- You can use Alpine.js for client-side interactions instead of JavaScript frameworks.
|
||||
- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
|
||||
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
|
||||
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
|
||||
- Do NOT delete tests without approval.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
68
api-data.md
68
api-data.md
@@ -1,68 +0,0 @@
|
||||
# External API & Data Sources
|
||||
|
||||
## UK Fuel Finder API (gov.uk) — PRIMARY SOURCE
|
||||
|
||||
- Base URL: `https://api.fuel-finder.service.gov.uk/`
|
||||
- Auth: OAuth 2.0 client credentials (client_id + client_secret → Bearer token)
|
||||
- Token stored in cache with TTL matching expiry minus 60 seconds
|
||||
- Returns: all UK station prices + station metadata
|
||||
- Update frequency: stations report within 30 minutes of price change
|
||||
- Our polling interval: every 15 minutes via scheduler
|
||||
|
||||
### FuelPriceService responsibilities
|
||||
1. Fetch OAuth token (cache it)
|
||||
2. GET all station prices
|
||||
3. Upsert `stations` table with metadata
|
||||
4. Insert new rows into `station_prices` only when price has changed for that station+fuel combo
|
||||
5. Call StationTaggingService to set `is_supermarket` and `brand`
|
||||
6. Dispatch `PricesUpdatedEvent` for downstream processing
|
||||
|
||||
### Deduplication
|
||||
Only insert a new `station_prices` row if price differs from the most recent stored price
|
||||
for that `(station_id, fuel_type)` combination. Avoids row explosion on unchanged prices.
|
||||
|
||||
### Credentials in .env
|
||||
```
|
||||
FUEL_FINDER_CLIENT_ID=
|
||||
FUEL_FINDER_CLIENT_SECRET=
|
||||
FUEL_FINDER_BASE_URL=https://api.fuel-finder.service.gov.uk
|
||||
```
|
||||
|
||||
## Postcodes.io — postcode → lat/lng
|
||||
|
||||
- URL: `https://api.postcodes.io/postcodes/{postcode}`
|
||||
- Free, no API key required
|
||||
- Called once on user registration / when postcode changes
|
||||
- Store resolved `lat` + `lng` on `users` table
|
||||
- Cache postcode lookups for 30 days (postcodes rarely change coordinates)
|
||||
|
||||
## FRED API (St. Louis Fed) — Brent crude direction
|
||||
|
||||
- Series: `DCOILBRENTEU` (daily Brent spot price)
|
||||
- URL: `https://api.stlouisfed.org/fred/series/observations?series_id=DCOILBRENTEU&api_key={key}&sort_order=desc&limit=10&file_type=json`
|
||||
- Free API key required — stored as `FRED_API_KEY` in .env
|
||||
- Fetched once daily via scheduler at 7am
|
||||
- Stored in `brent_prices` table: `(date DATE, price_usd DECIMAL(8,2))`
|
||||
- Only the 5-day trend direction is used by the scoring engine
|
||||
|
||||
## OneSignal — push notifications
|
||||
|
||||
- REST API: `https://oapi.onesignal.com/notifications`
|
||||
- App ID + REST API key stored in .env as `ONESIGNAL_APP_ID`, `ONESIGNAL_API_KEY`
|
||||
- Target by `player_id` (stored in `users.push_token`)
|
||||
- No official Laravel package needed — use Laravel HTTP client (`Http::post(...)`)
|
||||
- Free plan: 10,000 subscribers — sufficient for v1
|
||||
|
||||
## Vonage — WhatsApp + SMS
|
||||
|
||||
- Package: `vonage/client-core` via Composer
|
||||
- Credentials: `VONAGE_KEY`, `VONAGE_SECRET`, `VONAGE_WHATSAPP_FROM` in .env
|
||||
- WhatsApp: Messages API, utility template category (pre-approved)
|
||||
- SMS: SMS API, alphanumeric sender ID "FuelAlert"
|
||||
- All Vonage calls go through NotificationDispatchService — never call Vonage directly from components
|
||||
|
||||
## HTTP client
|
||||
|
||||
Use Laravel's built-in `Http` facade for all external API calls.
|
||||
Always set a timeout: `Http::timeout(10)->get(...)`.
|
||||
Wrap in try/catch — log failures, never let a failed API call crash the scheduler.
|
||||
18
app/Enums/FuelType.php
Normal file
18
app/Enums/FuelType.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum FuelType: string
|
||||
{
|
||||
case E10 = 'e10';
|
||||
case E5 = 'e5';
|
||||
case B7Standard = 'b7_standard';
|
||||
case B7Premium = 'b7_premium';
|
||||
case B10 = 'b10';
|
||||
case Hvo = 'hvo';
|
||||
|
||||
public static function fromApiValue(string $value): self
|
||||
{
|
||||
return self::from(strtolower($value));
|
||||
}
|
||||
}
|
||||
56
app/Models/Station.php
Normal file
56
app/Models/Station.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use Database\Factories\StationFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'node_id', 'trading_name', 'brand_name', 'is_same_trading_and_brand',
|
||||
'is_supermarket', 'is_motorway_service_station', 'is_supermarket_service_station',
|
||||
'temporary_closure', 'permanent_closure', 'permanent_closure_date',
|
||||
'public_phone_number', 'address_line_1', 'address_line_2', 'city',
|
||||
'county', 'country', 'postcode', 'lat', 'lng',
|
||||
'amenities', 'opening_times', 'fuel_types', 'last_seen_at',
|
||||
])]
|
||||
class Station extends Model
|
||||
{
|
||||
/** @use HasFactory<StationFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
protected $primaryKey = 'node_id';
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_same_trading_and_brand' => 'boolean',
|
||||
'is_supermarket' => 'boolean',
|
||||
'is_motorway_service_station' => 'boolean',
|
||||
'is_supermarket_service_station' => 'boolean',
|
||||
'temporary_closure' => 'boolean',
|
||||
'permanent_closure' => 'boolean',
|
||||
'permanent_closure_date' => 'date',
|
||||
'amenities' => 'array',
|
||||
'opening_times' => 'array',
|
||||
'fuel_types' => 'array',
|
||||
'last_seen_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function currentPrices(): HasMany
|
||||
{
|
||||
return $this->hasMany(StationPriceCurrent::class, 'station_id', 'node_id');
|
||||
}
|
||||
|
||||
public function prices(): HasMany
|
||||
{
|
||||
return $this->hasMany(StationPrice::class, 'station_id', 'node_id');
|
||||
}
|
||||
}
|
||||
34
app/Models/StationPrice.php
Normal file
34
app/Models/StationPrice.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use Database\Factories\StationPriceFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['station_id', 'fuel_type', 'price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'])]
|
||||
class StationPrice extends Model
|
||||
{
|
||||
/** @use HasFactory<StationPriceFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'fuel_type' => FuelType::class,
|
||||
'price_effective_at' => 'datetime',
|
||||
'price_reported_at' => 'datetime',
|
||||
'recorded_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function station(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Station::class, 'station_id', 'node_id');
|
||||
}
|
||||
}
|
||||
29
app/Models/StationPriceArchive.php
Normal file
29
app/Models/StationPriceArchive.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['station_id', 'fuel_type', 'price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'])]
|
||||
class StationPriceArchive extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'fuel_type' => FuelType::class,
|
||||
'price_effective_at' => 'datetime',
|
||||
'price_reported_at' => 'datetime',
|
||||
'recorded_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function station(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Station::class, 'station_id', 'node_id');
|
||||
}
|
||||
}
|
||||
41
app/Models/StationPriceCurrent.php
Normal file
41
app/Models/StationPriceCurrent.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use Database\Factories\StationPriceCurrentFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['station_id', 'fuel_type', 'price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'])]
|
||||
class StationPriceCurrent extends Model
|
||||
{
|
||||
/** @use HasFactory<StationPriceCurrentFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
protected $primaryKey = null;
|
||||
public $incrementing = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'fuel_type' => FuelType::class,
|
||||
'price_effective_at' => 'datetime',
|
||||
'price_reported_at' => 'datetime',
|
||||
'recorded_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function station(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Station::class, 'station_id', 'node_id');
|
||||
}
|
||||
|
||||
public function priceInPence(): float
|
||||
{
|
||||
return $this->price_pence / 100;
|
||||
}
|
||||
}
|
||||
69
app/Services/FuelPriceService.php
Normal file
69
app/Services/FuelPriceService.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Station;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class FuelPriceService
|
||||
{
|
||||
private const TOKEN_CACHE_KEY = 'fuel_finder_access_token';
|
||||
|
||||
public function __construct(
|
||||
private readonly StationTaggingService $taggingService,
|
||||
) {}
|
||||
|
||||
public function getAccessToken(): string
|
||||
{
|
||||
return Cache::remember(self::TOKEN_CACHE_KEY, 3540, function (): string {
|
||||
$response = Http::timeout(10)
|
||||
->post(config('services.fuel_finder.base_url').'/oauth/generate_access_token', [
|
||||
'client_id' => config('services.fuel_finder.client_id'),
|
||||
'client_secret' => config('services.fuel_finder.client_secret'),
|
||||
]);
|
||||
|
||||
return $response->json('data.access_token');
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $apiStations */
|
||||
public function upsertStations(array $apiStations): void
|
||||
{
|
||||
$now = now();
|
||||
$rows = [];
|
||||
|
||||
foreach ($apiStations as $data) {
|
||||
$station = new Station([
|
||||
'node_id' => $data['node_id'],
|
||||
'trading_name' => $data['trading_name'],
|
||||
'brand_name' => $data['brand_name'] ?? null,
|
||||
'is_same_trading_and_brand' => $data['is_same_trading_and_brand_name'] ?? false,
|
||||
'is_supermarket' => false,
|
||||
'is_motorway_service_station' => $data['is_motorway_service_station'] ?? false,
|
||||
'is_supermarket_service_station' => $data['is_supermarket_service_station'] ?? false,
|
||||
'temporary_closure' => $data['temporary_closure'] ?? false,
|
||||
'permanent_closure' => $data['permanent_closure'] ?? false,
|
||||
'permanent_closure_date' => $data['permanent_closure_date'] ?? null,
|
||||
'public_phone_number' => $data['public_phone_number'] ?? null,
|
||||
'address_line_1' => $data['location']['address_line_1'] ?? null,
|
||||
'address_line_2' => $data['location']['address_line_2'] ?? null,
|
||||
'city' => $data['location']['city'] ?? null,
|
||||
'county' => $data['location']['county'] ?? null,
|
||||
'country' => $data['location']['country'] ?? null,
|
||||
'postcode' => $data['location']['postcode'],
|
||||
'lat' => $data['location']['latitude'],
|
||||
'lng' => $data['location']['longitude'],
|
||||
'amenities' => $data['amenities'] ?? [],
|
||||
'opening_times' => $data['opening_times'] ?? null,
|
||||
'fuel_types' => $data['fuel_types'] ?? [],
|
||||
'last_seen_at' => $now,
|
||||
]);
|
||||
|
||||
$this->taggingService->tag($station);
|
||||
$rows[] = $station->getAttributes();
|
||||
}
|
||||
|
||||
Station::upsert($rows, ['node_id'], array_keys($rows[0] ?? []));
|
||||
}
|
||||
}
|
||||
32
app/Services/StationTaggingService.php
Normal file
32
app/Services/StationTaggingService.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Station;
|
||||
|
||||
class StationTaggingService
|
||||
{
|
||||
/** @var array<string, string> brand keyword → normalised brand name */
|
||||
private const SUPERMARKET_BRANDS = [
|
||||
'tesco' => 'Tesco',
|
||||
'asda' => 'Asda',
|
||||
'morrisons' => 'Morrisons',
|
||||
'sainsbury' => 'Sainsbury\'s',
|
||||
'aldi' => 'Aldi',
|
||||
'lidl' => 'Lidl',
|
||||
'costco' => 'Costco',
|
||||
];
|
||||
|
||||
public function tag(Station $station): void
|
||||
{
|
||||
$name = strtolower($station->trading_name);
|
||||
|
||||
foreach (self::SUPERMARKET_BRANDS as $keyword => $normalisedBrand) {
|
||||
if (str_contains($name, $keyword)) {
|
||||
$station->is_supermarket = true;
|
||||
$station->brand_name = $normalisedBrand;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
boost.json
Normal file
16
boost.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"agents": [
|
||||
"claude_code"
|
||||
],
|
||||
"guidelines": true,
|
||||
"mcp": true,
|
||||
"nightwatch_mcp": false,
|
||||
"sail": false,
|
||||
"skills": [
|
||||
"laravel-best-practices",
|
||||
"fluxui-development",
|
||||
"livewire-development",
|
||||
"pest-testing",
|
||||
"tailwindcss-development"
|
||||
]
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.24",
|
||||
"laravel/boost": "^2.4",
|
||||
"laravel/pail": "^1.2.5",
|
||||
"laravel/pint": "^1.27",
|
||||
"laravel/sail": "^1.53",
|
||||
|
||||
202
composer.lock
generated
202
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "a307e12c386fc69ff5ce035bc4b51c25",
|
||||
"content-hash": "23c16a0ec7837a1a633e710abe09b493",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@@ -6765,6 +6765,145 @@
|
||||
},
|
||||
"time": "2025-03-19T14:43:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/boost",
|
||||
"version": "v2.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/boost.git",
|
||||
"reference": "f6241df9fd81a86d79a051851177d4ffe3e28506"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/boost/zipball/f6241df9fd81a86d79a051851177d4ffe3e28506",
|
||||
"reference": "f6241df9fd81a86d79a051851177d4ffe3e28506",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"guzzlehttp/guzzle": "^7.9",
|
||||
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
|
||||
"laravel/mcp": "^0.5.1|^0.6.0",
|
||||
"laravel/prompts": "^0.3.10",
|
||||
"laravel/roster": "^0.5.0",
|
||||
"php": "^8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "^1.27.0",
|
||||
"mockery/mockery": "^1.6.12",
|
||||
"orchestra/testbench": "^9.15.0|^10.6|^11.0",
|
||||
"pestphp/pest": "^2.36.0|^3.8.4|^4.1.5",
|
||||
"phpstan/phpstan": "^2.1.27",
|
||||
"rector/rector": "^2.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Boost\\BoostServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Boost\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.",
|
||||
"homepage": "https://github.com/laravel/boost",
|
||||
"keywords": [
|
||||
"ai",
|
||||
"dev",
|
||||
"laravel"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/boost/issues",
|
||||
"source": "https://github.com/laravel/boost"
|
||||
},
|
||||
"time": "2026-03-25T16:37:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/mcp",
|
||||
"version": "v0.6.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/mcp.git",
|
||||
"reference": "583a6282bf0f074d754f7ff5cd1fff9d34244691"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/mcp/zipball/583a6282bf0f074d754f7ff5cd1fff9d34244691",
|
||||
"reference": "583a6282bf0f074d754f7ff5cd1fff9d34244691",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/container": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/http": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/json-schema": "^12.41.1|^13.0",
|
||||
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
|
||||
"illuminate/validation": "^11.45.3|^12.41.1|^13.0",
|
||||
"php": "^8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "^1.20",
|
||||
"orchestra/testbench": "^9.15|^10.8|^11.0",
|
||||
"pestphp/pest": "^3.8.5|^4.3.2",
|
||||
"phpstan/phpstan": "^2.1.27",
|
||||
"rector/rector": "^2.2.4"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
|
||||
},
|
||||
"providers": [
|
||||
"Laravel\\Mcp\\Server\\McpServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Mcp\\": "src/",
|
||||
"Laravel\\Mcp\\Server\\": "src/Server/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Rapidly build MCP servers for your Laravel applications.",
|
||||
"homepage": "https://github.com/laravel/mcp",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"mcp"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/mcp/issues",
|
||||
"source": "https://github.com/laravel/mcp"
|
||||
},
|
||||
"time": "2026-03-30T19:17:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/pail",
|
||||
"version": "v1.2.6",
|
||||
@@ -6913,6 +7052,67 @@
|
||||
},
|
||||
"time": "2026-03-12T15:51:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/roster",
|
||||
"version": "v0.5.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/roster.git",
|
||||
"reference": "5089de7615f72f78e831590ff9d0435fed0102bb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/roster/zipball/5089de7615f72f78e831590ff9d0435fed0102bb",
|
||||
"reference": "5089de7615f72f78e831590ff9d0435fed0102bb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/console": "^11.0|^12.0|^13.0",
|
||||
"illuminate/contracts": "^11.0|^12.0|^13.0",
|
||||
"illuminate/routing": "^11.0|^12.0|^13.0",
|
||||
"illuminate/support": "^11.0|^12.0|^13.0",
|
||||
"php": "^8.2",
|
||||
"symfony/yaml": "^7.2|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "^1.14",
|
||||
"mockery/mockery": "^1.6",
|
||||
"orchestra/testbench": "^9.0|^10.0|^11.0",
|
||||
"pestphp/pest": "^3.0|^4.1",
|
||||
"phpstan/phpstan": "^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Roster\\RosterServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Roster\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "Detect packages & approaches in use within a Laravel project",
|
||||
"homepage": "https://github.com/laravel/roster",
|
||||
"keywords": [
|
||||
"dev",
|
||||
"laravel"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/roster/issues",
|
||||
"source": "https://github.com/laravel/roster"
|
||||
},
|
||||
"time": "2026-03-05T07:58:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/sail",
|
||||
"version": "v1.56.0",
|
||||
|
||||
@@ -35,4 +35,10 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'fuel_finder' => [
|
||||
'base_url' => env('FUEL_FINDER_BASE_URL', 'https://www.fuel-finder.service.gov.uk/api/v1'),
|
||||
'client_id' => env('FUEL_FINDER_CLIENT_ID'),
|
||||
'client_secret' => env('FUEL_FINDER_CLIENT_SECRET'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
50
database/factories/StationFactory.php
Normal file
50
database/factories/StationFactory.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Station;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<Station> */
|
||||
class StationFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
$trading = 'Station ' . str()->random(8);
|
||||
|
||||
return [
|
||||
'node_id' => (string) str()->ulid(),
|
||||
'trading_name' => $trading,
|
||||
'brand_name' => $trading,
|
||||
'is_same_trading_and_brand' => true,
|
||||
'is_supermarket' => false,
|
||||
'is_motorway_service_station' => false,
|
||||
'is_supermarket_service_station' => false,
|
||||
'temporary_closure' => false,
|
||||
'permanent_closure' => false,
|
||||
'permanent_closure_date' => null,
|
||||
'public_phone_number' => null,
|
||||
'address_line_1' => 'Address ' . str()->random(6),
|
||||
'address_line_2' => null,
|
||||
'city' => 'City ' . str()->random(6),
|
||||
'county' => null,
|
||||
'country' => 'England',
|
||||
'postcode' => strtoupper(str()->random(6)),
|
||||
'lat' => random_int(499, 609) / 10.0,
|
||||
'lng' => random_int(-82, 18) / 10.0,
|
||||
'amenities' => [],
|
||||
'opening_times' => null,
|
||||
'fuel_types' => ['E10', 'E5'],
|
||||
'last_seen_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
public function supermarket(): static
|
||||
{
|
||||
return $this->state([
|
||||
'trading_name' => 'Tesco',
|
||||
'brand_name' => 'Tesco',
|
||||
'is_supermarket' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
24
database/factories/StationPriceCurrentFactory.php
Normal file
24
database/factories/StationPriceCurrentFactory.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use App\Models\Station;
|
||||
use App\Models\StationPriceCurrent;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<StationPriceCurrent> */
|
||||
class StationPriceCurrentFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'station_id' => Station::factory(),
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => $this->faker->numberBetween(12000, 18000),
|
||||
'price_effective_at' => now()->subHour(),
|
||||
'price_reported_at' => now()->subMinutes(30),
|
||||
'recorded_at' => now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
24
database/factories/StationPriceFactory.php
Normal file
24
database/factories/StationPriceFactory.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use App\Models\Station;
|
||||
use App\Models\StationPrice;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<StationPrice> */
|
||||
class StationPriceFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'station_id' => Station::factory(),
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => $this->faker->numberBetween(12000, 18000),
|
||||
'price_effective_at' => now()->subDays($this->faker->numberBetween(1, 30)),
|
||||
'price_reported_at' => now()->subDays($this->faker->numberBetween(1, 30)),
|
||||
'recorded_at' => now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('stations', function (Blueprint $table): void {
|
||||
$table->string('node_id', 64)->primary();
|
||||
$table->string('trading_name', 128);
|
||||
$table->string('brand_name', 64)->nullable();
|
||||
$table->boolean('is_same_trading_and_brand')->default(false);
|
||||
$table->boolean('is_supermarket')->default(false)->comment('Set by StationTaggingService');
|
||||
$table->boolean('is_motorway_service_station')->default(false);
|
||||
$table->boolean('is_supermarket_service_station')->default(false);
|
||||
$table->boolean('temporary_closure')->default(false);
|
||||
$table->boolean('permanent_closure')->default(false);
|
||||
$table->date('permanent_closure_date')->nullable();
|
||||
$table->string('public_phone_number', 20)->nullable();
|
||||
$table->string('address_line_1', 255)->nullable();
|
||||
$table->string('address_line_2', 255)->nullable();
|
||||
$table->string('city', 100)->nullable();
|
||||
$table->string('county', 100)->nullable();
|
||||
$table->string('country', 64)->nullable();
|
||||
$table->string('postcode', 10);
|
||||
$table->decimal('lat', 10, 7);
|
||||
$table->decimal('lng', 10, 7);
|
||||
$table->json('amenities')->nullable();
|
||||
$table->json('opening_times')->nullable();
|
||||
$table->json('fuel_types')->nullable();
|
||||
$table->dateTime('last_seen_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('stations');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('station_prices_current', function (Blueprint $table): void {
|
||||
$table->string('station_id', 64);
|
||||
$table->string('fuel_type', 20);
|
||||
$table->unsignedSmallInteger('price_pence')->comment('Price in pence × 100, e.g. 15990 = 159.90p');
|
||||
$table->dateTime('price_effective_at')->comment('price_change_effective_timestamp from API');
|
||||
$table->dateTime('price_reported_at')->comment('price_last_updated from API');
|
||||
$table->dateTime('recorded_at')->comment('When this row was last upserted by us');
|
||||
|
||||
$table->primary(['station_id', 'fuel_type']);
|
||||
$table->foreign('station_id')->references('node_id')->on('stations')->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('station_prices_current');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$isMysql = DB::getDriverName() === 'mysql';
|
||||
|
||||
Schema::create('station_prices', function (Blueprint $table) use ($isMysql): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('station_id', 64);
|
||||
$table->string('fuel_type', 20);
|
||||
$table->unsignedSmallInteger('price_pence')->comment('Price in pence × 100');
|
||||
$table->dateTime('price_effective_at');
|
||||
$table->dateTime('price_reported_at');
|
||||
$table->dateTime('recorded_at');
|
||||
|
||||
// Composite PK required for MySQL range partitioning (not supported by SQLite)
|
||||
if ($isMysql) {
|
||||
$table->primary(['id', 'price_effective_at']);
|
||||
}
|
||||
$table->index(['station_id', 'fuel_type', 'price_effective_at']);
|
||||
$table->index('price_effective_at');
|
||||
});
|
||||
|
||||
// Monthly partitions 2026–2027 + MAXVALUE catch-all (MySQL only)
|
||||
if ($isMysql) {
|
||||
DB::statement("ALTER TABLE station_prices
|
||||
PARTITION BY RANGE (YEAR(price_effective_at) * 100 + MONTH(price_effective_at)) (
|
||||
PARTITION p202601 VALUES LESS THAN (202602),
|
||||
PARTITION p202602 VALUES LESS THAN (202603),
|
||||
PARTITION p202603 VALUES LESS THAN (202604),
|
||||
PARTITION p202604 VALUES LESS THAN (202605),
|
||||
PARTITION p202605 VALUES LESS THAN (202606),
|
||||
PARTITION p202606 VALUES LESS THAN (202607),
|
||||
PARTITION p202607 VALUES LESS THAN (202608),
|
||||
PARTITION p202608 VALUES LESS THAN (202609),
|
||||
PARTITION p202609 VALUES LESS THAN (202610),
|
||||
PARTITION p202610 VALUES LESS THAN (202611),
|
||||
PARTITION p202611 VALUES LESS THAN (202612),
|
||||
PARTITION p202612 VALUES LESS THAN (202701),
|
||||
PARTITION p202701 VALUES LESS THAN (202702),
|
||||
PARTITION p202702 VALUES LESS THAN (202703),
|
||||
PARTITION p202703 VALUES LESS THAN (202704),
|
||||
PARTITION p202704 VALUES LESS THAN (202705),
|
||||
PARTITION p202705 VALUES LESS THAN (202706),
|
||||
PARTITION p202706 VALUES LESS THAN (202707),
|
||||
PARTITION p202707 VALUES LESS THAN (202708),
|
||||
PARTITION p202708 VALUES LESS THAN (202709),
|
||||
PARTITION p202709 VALUES LESS THAN (202710),
|
||||
PARTITION p202710 VALUES LESS THAN (202711),
|
||||
PARTITION p202711 VALUES LESS THAN (202712),
|
||||
PARTITION p202712 VALUES LESS THAN (202801),
|
||||
PARTITION pFuture VALUES LESS THAN MAXVALUE
|
||||
)");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('station_prices');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('station_prices_archive', function (Blueprint $table): void {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('station_id', 64);
|
||||
$table->string('fuel_type', 20);
|
||||
$table->unsignedSmallInteger('price_pence');
|
||||
$table->dateTime('price_effective_at');
|
||||
$table->dateTime('price_reported_at');
|
||||
$table->dateTime('recorded_at');
|
||||
|
||||
$table->index(['station_id', 'fuel_type', 'price_effective_at'], 'spa_station_fuel_effective_idx');
|
||||
$table->index('price_effective_at', 'spa_price_effective_at_idx');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('station_prices_archive');
|
||||
}
|
||||
};
|
||||
1686
docs/superpowers/plans/2026-04-03-fuel-api-ingestion.md
Normal file
1686
docs/superpowers/plans/2026-04-03-fuel-api-ingestion.md
Normal file
File diff suppressed because it is too large
Load Diff
162
docs/superpowers/specs/2026-04-03-fuel-api-ingestion-design.md
Normal file
162
docs/superpowers/specs/2026-04-03-fuel-api-ingestion-design.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Fuel API Ingestion & Historic Storage Design
|
||||
|
||||
**Date:** 2026-04-03
|
||||
**Scope:** UK Fuel Finder API integration, database schema for station metadata and historic price storage.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The app polls the UK gov.uk Fuel Finder API to collect petrol station prices across the UK (~14,500 stations). Prices are used by the scoring engine to produce fill-up recommendations for users. Historic data is retained indefinitely — a hot table covers the last year for scoring queries, an archive table holds everything older for graphs and comparisons.
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
**Base URL:** `https://www.fuel-finder.service.gov.uk/api/v1`
|
||||
|
||||
### Authentication
|
||||
|
||||
OAuth 2.0 via JSON POST (not form-encoded).
|
||||
|
||||
- **Get token:** `POST /oauth/generate_access_token` `{"client_id": "...", "client_secret": "..."}`
|
||||
- **Refresh token:** `POST /oauth/regenerate_access_token` same payload
|
||||
- Response includes `access_token` (Bearer), `expires_in: 3600`, `refresh_token`
|
||||
- Cache token at key `fuel_finder_access_token` with TTL = `expires_in - 60` (3540s)
|
||||
- On cache miss: fetch new token, store, return
|
||||
|
||||
### Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/pfs?batch-number={n}` | Station metadata, 500 per batch |
|
||||
| GET | `/pfs/fuel-prices?batch-number={n}` | All station prices, 500 per batch |
|
||||
| GET | `/pfs/fuel-prices` | Incremental prices (recently changed only) |
|
||||
|
||||
- `node_id` is the station identifier — consistent across both endpoints (verified against live API)
|
||||
- Both endpoints return a flat JSON array (no pagination wrapper)
|
||||
- Total stations: ~14,500 across ~30 batches
|
||||
- Fuel types in production: `E10`, `E5`, `B7_STANDARD`, `B7_PREMIUM`, `HVO`, `B10`
|
||||
|
||||
### Polling strategy
|
||||
|
||||
- **Every 15 minutes:** call `/pfs/fuel-prices` (no batch-number) — returns only recently changed prices
|
||||
- **Once daily (3am):** full refresh — iterate all batches of both `/pfs` and `/pfs/fuel-prices` to catch any drift
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### `stations`
|
||||
|
||||
One row per petrol filling station. Upserted on full daily refresh and when an incremental poll encounters a new `node_id`.
|
||||
|
||||
```
|
||||
node_id VARCHAR(64) PRIMARY KEY
|
||||
trading_name VARCHAR(128)
|
||||
brand_name VARCHAR(64) NULLABLE
|
||||
is_same_trading_and_brand TINYINT(1)
|
||||
is_supermarket TINYINT(1) DEFAULT 0 — set by StationTaggingService
|
||||
is_motorway_service_station TINYINT(1) DEFAULT 0
|
||||
is_supermarket_service_station TINYINT(1) DEFAULT 0
|
||||
temporary_closure TINYINT(1) DEFAULT 0
|
||||
permanent_closure TINYINT(1) DEFAULT 0
|
||||
permanent_closure_date DATE NULLABLE
|
||||
public_phone_number VARCHAR(20) NULLABLE
|
||||
address_line_1 VARCHAR(255) NULLABLE
|
||||
address_line_2 VARCHAR(255) NULLABLE
|
||||
city VARCHAR(100) NULLABLE
|
||||
county VARCHAR(100) NULLABLE
|
||||
country VARCHAR(64) NULLABLE
|
||||
postcode VARCHAR(10)
|
||||
lat DECIMAL(10,7)
|
||||
lng DECIMAL(10,7)
|
||||
amenities JSON NULLABLE
|
||||
opening_times JSON NULLABLE
|
||||
fuel_types JSON NULLABLE — array of supported fuel type strings
|
||||
last_seen_at DATETIME
|
||||
```
|
||||
|
||||
### `station_prices_current`
|
||||
|
||||
One row per `(station_id, fuel_type)`. Upserted on every price change. Used by scoring engine for current-price lookups — never needs to touch the history table.
|
||||
|
||||
```
|
||||
station_id VARCHAR(64) FK → stations.node_id
|
||||
fuel_type ENUM('e10','e5','b7_standard','b7_premium','b10','hvo')
|
||||
price_pence SMALLINT UNSIGNED — price × 100 (e.g. 15990 = 159.90p, never float)
|
||||
price_effective_at DATETIME — price_change_effective_timestamp from API
|
||||
price_reported_at DATETIME — price_last_updated from API
|
||||
recorded_at DATETIME — when this row was last upserted
|
||||
|
||||
PRIMARY KEY (station_id, fuel_type)
|
||||
```
|
||||
|
||||
### `station_prices`
|
||||
|
||||
Append-only price history. One row per price change per station+fuel. Partitioned monthly on `price_effective_at`. Covers the last 12 months (hot table).
|
||||
|
||||
```
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT
|
||||
station_id VARCHAR(64) FK → stations.node_id
|
||||
fuel_type ENUM('e10','e5','b7_standard','b7_premium','b10','hvo')
|
||||
price_pence SMALLINT UNSIGNED
|
||||
price_effective_at DATETIME
|
||||
price_reported_at DATETIME
|
||||
recorded_at DATETIME
|
||||
|
||||
PRIMARY KEY (id, price_effective_at)
|
||||
INDEX (station_id, fuel_type, price_effective_at)
|
||||
INDEX (price_effective_at)
|
||||
PARTITION BY RANGE (YEAR(price_effective_at) * 100 + MONTH(price_effective_at))
|
||||
```
|
||||
|
||||
**Deduplication:** only insert a new row if `price_pence` differs from the most recent stored value for that `(station_id, fuel_type)`. This prevents duplicates on full refreshes when prices haven't changed.
|
||||
|
||||
### `station_prices_archive`
|
||||
|
||||
Identical schema to `station_prices` but no partitioning. Rows older than 12 months are moved here by a monthly scheduled command. Used only for trend graphs and historical comparisons — never queried by the scoring engine.
|
||||
|
||||
```
|
||||
(same columns as station_prices — no partition)
|
||||
INDEX (station_id, fuel_type, price_effective_at)
|
||||
INDEX (price_effective_at)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationships
|
||||
|
||||
```
|
||||
stations.node_id ←── station_prices_current.station_id
|
||||
←── station_prices.station_id
|
||||
←── station_prices_archive.station_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service responsibilities
|
||||
|
||||
**`FuelPriceService`**
|
||||
1. Fetch/cache OAuth token
|
||||
2. Incremental poll every 15 min: GET `/pfs/fuel-prices`, upsert `station_prices_current`, insert into `station_prices` where price changed
|
||||
3. Full refresh daily: iterate all batches of `/pfs` (upsert `stations`) and `/pfs/fuel-prices` (same price logic)
|
||||
4. Call `StationTaggingService` to set `is_supermarket` and normalise `brand_name`
|
||||
5. Dispatch `PricesUpdatedEvent` after each poll
|
||||
|
||||
**`StationTaggingService`**
|
||||
- Matches `trading_name` against known supermarket brands (case-insensitive)
|
||||
- Sets `is_supermarket = 1` and normalises `brand_name`
|
||||
|
||||
**Scheduled archive command**
|
||||
- Runs monthly
|
||||
- Moves rows from `station_prices` where `price_effective_at < NOW() - 12 months` into `station_prices_archive`
|
||||
- Drops the corresponding old partition from `station_prices`
|
||||
|
||||
---
|
||||
|
||||
## Open questions / adjustable later
|
||||
|
||||
- Exact partition pre-creation strategy (how many months ahead to create partitions)
|
||||
- Whether `station_prices_archive` needs its own partitioning if it grows very large
|
||||
- Additional fuel types if the API introduces new ones (extend ENUM in migration)
|
||||
@@ -16,7 +16,7 @@ use Tests\TestCase;
|
||||
|
||||
pest()->extend(TestCase::class)
|
||||
// ->use(RefreshDatabase::class)
|
||||
->in('Feature');
|
||||
->in('Feature', 'Unit');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
114
tests/Unit/Services/FuelPriceServiceTest.php
Normal file
114
tests/Unit/Services/FuelPriceServiceTest.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Station;
|
||||
use App\Models\StationPrice;
|
||||
use App\Models\StationPriceCurrent;
|
||||
use App\Services\FuelPriceService;
|
||||
use App\Services\StationTaggingService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->service = new FuelPriceService(new StationTaggingService());
|
||||
});
|
||||
|
||||
it('fetches and caches an access token', function (): void {
|
||||
Http::fake([
|
||||
'*/oauth/generate_access_token' => Http::response([
|
||||
'data' => [
|
||||
'access_token' => 'test-token-abc',
|
||||
'expires_in' => 3600,
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$token = $this->service->getAccessToken();
|
||||
|
||||
expect($token)->toBe('test-token-abc');
|
||||
expect(Cache::get('fuel_finder_access_token'))->toBe('test-token-abc');
|
||||
});
|
||||
|
||||
it('returns cached token without hitting API', function (): void {
|
||||
Cache::put('fuel_finder_access_token', 'cached-token', 3540);
|
||||
|
||||
Http::fake();
|
||||
|
||||
$token = $this->service->getAccessToken();
|
||||
|
||||
expect($token)->toBe('cached-token');
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('upserts stations from API batch response', function (): void {
|
||||
$apiStations = [
|
||||
[
|
||||
'node_id' => 'abc123',
|
||||
'trading_name' => 'Village Garage',
|
||||
'brand_name' => 'Village Garage',
|
||||
'is_same_trading_and_brand_name' => true,
|
||||
'is_motorway_service_station' => false,
|
||||
'is_supermarket_service_station' => false,
|
||||
'temporary_closure' => false,
|
||||
'permanent_closure' => false,
|
||||
'permanent_closure_date' => null,
|
||||
'public_phone_number' => null,
|
||||
'location' => [
|
||||
'address_line_1' => '1 High Street',
|
||||
'address_line_2' => null,
|
||||
'city' => 'London',
|
||||
'county' => null,
|
||||
'country' => 'England',
|
||||
'postcode' => 'SW1A 1AA',
|
||||
'latitude' => 51.5,
|
||||
'longitude' => -0.1,
|
||||
],
|
||||
'amenities' => [],
|
||||
'opening_times'=> null,
|
||||
'fuel_types' => ['E10', 'E5'],
|
||||
],
|
||||
];
|
||||
|
||||
$this->service->upsertStations($apiStations);
|
||||
|
||||
$station = Station::find('abc123');
|
||||
expect($station)->not->toBeNull()
|
||||
->and($station->trading_name)->toBe('Village Garage')
|
||||
->and($station->postcode)->toBe('SW1A 1AA')
|
||||
->and((float) $station->lat)->toBe(51.5)
|
||||
->and($station->is_supermarket)->toBeFalse();
|
||||
});
|
||||
|
||||
it('tags supermarket stations during upsert', function (): void {
|
||||
$apiStations = [[
|
||||
'node_id' => 'tesco1',
|
||||
'trading_name' => 'TESCO',
|
||||
'brand_name' => 'TESCO',
|
||||
'is_same_trading_and_brand_name' => true,
|
||||
'is_motorway_service_station' => false,
|
||||
'is_supermarket_service_station' => true,
|
||||
'temporary_closure' => false,
|
||||
'permanent_closure' => false,
|
||||
'permanent_closure_date' => null,
|
||||
'public_phone_number' => null,
|
||||
'location' => [
|
||||
'address_line_1' => '1 Tesco Way',
|
||||
'address_line_2' => null,
|
||||
'city' => 'Bristol',
|
||||
'county' => null,
|
||||
'country' => 'England',
|
||||
'postcode' => 'BS1 1AA',
|
||||
'latitude' => 51.45,
|
||||
'longitude' => -2.6,
|
||||
],
|
||||
'amenities' => [],
|
||||
'opening_times'=> null,
|
||||
'fuel_types' => ['E10'],
|
||||
]];
|
||||
|
||||
$this->service->upsertStations($apiStations);
|
||||
|
||||
expect(Station::find('tesco1')->is_supermarket)->toBeTrue();
|
||||
});
|
||||
68
tests/Unit/Services/StationTaggingServiceTest.php
Normal file
68
tests/Unit/Services/StationTaggingServiceTest.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Station;
|
||||
use App\Services\StationTaggingService;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->service = new StationTaggingService();
|
||||
});
|
||||
|
||||
it('marks tesco station as supermarket and normalises brand', function (): void {
|
||||
$station = new Station([
|
||||
'trading_name' => 'TESCO EXTRA',
|
||||
'is_supermarket' => false,
|
||||
]);
|
||||
|
||||
$this->service->tag($station);
|
||||
|
||||
expect($station->is_supermarket)->toBeTrue()
|
||||
->and($station->brand_name)->toBe('Tesco');
|
||||
});
|
||||
|
||||
it('marks asda station as supermarket', function (): void {
|
||||
$station = new Station([
|
||||
'trading_name' => 'Asda Petrol Station',
|
||||
'is_supermarket' => false,
|
||||
]);
|
||||
|
||||
$this->service->tag($station);
|
||||
|
||||
expect($station->is_supermarket)->toBeTrue()
|
||||
->and($station->brand_name)->toBe('Asda');
|
||||
});
|
||||
|
||||
it('does not mark independent station as supermarket', function (): void {
|
||||
$station = new Station([
|
||||
'trading_name' => 'Village Garage',
|
||||
'is_supermarket' => false,
|
||||
]);
|
||||
|
||||
$this->service->tag($station);
|
||||
|
||||
expect($station->is_supermarket)->toBeFalse();
|
||||
});
|
||||
|
||||
it('handles case insensitive matching', function (): void {
|
||||
$station = new Station([
|
||||
'trading_name' => 'morrisons petrol',
|
||||
'is_supermarket' => false,
|
||||
]);
|
||||
|
||||
$this->service->tag($station);
|
||||
|
||||
expect($station->is_supermarket)->toBeTrue()
|
||||
->and($station->brand_name)->toBe('Morrisons');
|
||||
});
|
||||
|
||||
it('does not overwrite brand_name for non-supermarket stations', function (): void {
|
||||
$station = new Station([
|
||||
'trading_name' => 'Shell Garage',
|
||||
'brand_name' => 'Shell',
|
||||
'is_supermarket' => false,
|
||||
]);
|
||||
|
||||
$this->service->tag($station);
|
||||
|
||||
expect($station->is_supermarket)->toBeFalse()
|
||||
->and($station->brand_name)->toBe('Shell');
|
||||
});
|
||||
Reference in New Issue
Block a user