51 KiB
Fuel API Ingestion Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement the full UK Fuel Finder API ingestion pipeline — OAuth token caching, station metadata upsert, price change detection, historic storage, and scheduled polling.
Architecture: FuelPriceService handles all API communication and DB writes. StationTaggingService classifies supermarket stations. Two console commands drive polling (PollFuelPricesCommand) and archive rotation (ArchiveOldPricesCommand). A PricesUpdatedEvent is fired after each poll for downstream use.
Tech Stack: Laravel 13, PHP 8.4, MySQL (InnoDB + monthly partitions), Pest 4, Laravel HTTP client, Laravel Cache (database driver), Laravel Scheduler.
File Map
| File | Action | Purpose |
|---|---|---|
config/services.php |
Modify | Add fuel_finder config block |
app/Enums/FuelType.php |
Create | Backed enum for fuel types |
database/migrations/*_create_stations_table.php |
Create | Station metadata table |
database/migrations/*_create_station_prices_current_table.php |
Create | Latest price per station+fuel |
database/migrations/*_create_station_prices_table.php |
Create | Partitioned price history |
database/migrations/*_create_station_prices_archive_table.php |
Create | Archive for prices > 1 year |
app/Models/Station.php |
Create | Eloquent model |
app/Models/StationPriceCurrent.php |
Create | Eloquent model |
app/Models/StationPrice.php |
Create | Eloquent model |
app/Models/StationPriceArchive.php |
Create | Eloquent model |
database/factories/StationFactory.php |
Create | Test factory |
database/factories/StationPriceCurrentFactory.php |
Create | Test factory |
database/factories/StationPriceFactory.php |
Create | Test factory |
app/Services/StationTaggingService.php |
Create | Supermarket brand detection |
app/Services/FuelPriceService.php |
Create | API client + ingestion logic |
app/Events/PricesUpdatedEvent.php |
Create | Fired after each poll |
app/Console/Commands/PollFuelPricesCommand.php |
Create | 15-min incremental + daily full |
app/Console/Commands/ArchiveOldPricesCommand.php |
Create | Move old rows to archive |
bootstrap/app.php |
Modify | Register scheduler |
tests/Unit/Services/StationTaggingServiceTest.php |
Create | Unit tests |
tests/Unit/Services/FuelPriceServiceTest.php |
Create | Unit tests with Http::fake() |
tests/Feature/Commands/PollFuelPricesCommandTest.php |
Create | Command integration tests |
tests/Feature/Commands/ArchiveOldPricesCommandTest.php |
Create | Archive command tests |
Task 1: Add fuel_finder config
Files:
-
Modify:
config/services.php -
Step 1: Add config block
In config/services.php, add before the closing ]:
'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'),
],
- Step 2: Verify .env already has the keys
Check .env contains:
FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
FUEL_FINDER_CLIENT_ID=2MJ9mYAmZMApMF6fHT2rwd8pSL3JP9D3
FUEL_FINDER_CLIENT_SECRET=oAZsWBagGD8YTn68BnWDjbV61SA7nOrHHlzKhH3s3oMj23kv0MsI8l25ILUbL5j6
- Step 3: Commit
git add config/services.php
git commit -m "config: add fuel_finder service credentials"
Task 2: FuelType enum
Files:
-
Create:
app/Enums/FuelType.php -
Step 1: Create the enum
<?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));
}
}
- Step 2: Commit
git add app/Enums/FuelType.php
git commit -m "feat: add FuelType backed enum"
Task 3: Migrations
Files:
-
Create: 4 migration files
-
Step 1: Generate migration stubs
php artisan make:migration create_stations_table
php artisan make:migration create_station_prices_current_table
php artisan make:migration create_station_prices_table
php artisan make:migration create_station_prices_archive_table
- Step 2: Implement stations migration
Replace the generated up() method:
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');
});
}
- Step 3: Implement station_prices_current migration
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();
});
}
- Step 4: Implement station_prices migration (partitioned)
public function up(): void
{
Schema::create('station_prices', function (Blueprint $table): 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
$table->primary(['id', 'price_effective_at']);
$table->index(['station_id', 'fuel_type', 'price_effective_at']);
$table->index('price_effective_at');
});
// Add monthly partitions for 2026-2027, with MAXVALUE catch-all
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
)");
}
public function down(): void
{
Schema::dropIfExists('station_prices');
}
- Step 5: Implement station_prices_archive migration
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']);
$table->index('price_effective_at');
});
}
- Step 6: Run migrations
php artisan migrate
Expected: 4 new tables created with no errors.
- Step 7: Commit
git add database/migrations/
git commit -m "feat: add stations and station prices migrations"
Task 4: Eloquent models
Files:
-
Create:
app/Models/Station.php -
Create:
app/Models/StationPriceCurrent.php -
Create:
app/Models/StationPrice.php -
Create:
app/Models/StationPriceArchive.php -
Step 1: Create Station model
<?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');
}
}
- Step 2: Create StationPriceCurrent model
<?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');
}
/** Price in pence as a float, e.g. 159.90 */
public function priceInPence(): float
{
return $this->price_pence / 100;
}
}
- Step 3: Create StationPrice model
<?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');
}
}
- Step 4: Create StationPriceArchive model
<?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');
}
}
- Step 5: Commit
git add app/Models/ app/Enums/
git commit -m "feat: add Station, StationPrice, StationPriceCurrent, StationPriceArchive models"
Task 5: Factories
Files:
-
Create:
database/factories/StationFactory.php -
Create:
database/factories/StationPriceCurrentFactory.php -
Create:
database/factories/StationPriceFactory.php -
Step 1: Create StationFactory
<?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 = $this->faker->company();
return [
'node_id' => hash('sha256', $this->faker->unique()->uuid()),
'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' => $this->faker->streetAddress(),
'address_line_2' => null,
'city' => $this->faker->city(),
'county' => null,
'country' => 'England',
'postcode' => strtoupper($this->faker->postcode()),
'lat' => $this->faker->latitude(49.9, 60.9),
'lng' => $this->faker->longitude(-8.2, 1.8),
'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,
]);
}
}
- Step 2: Create StationPriceCurrentFactory
<?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(),
];
}
}
- Step 3: Create StationPriceFactory
<?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(),
];
}
}
- Step 4: Commit
git add database/factories/
git commit -m "feat: add Station, StationPrice, StationPriceCurrent factories"
Task 6: StationTaggingService
Files:
-
Create:
app/Services/StationTaggingService.php -
Create:
tests/Unit/Services/StationTaggingServiceTest.php -
Step 1: Write failing tests
<?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 = Station::factory()->make(['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 = Station::factory()->make(['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 = Station::factory()->make(['trading_name' => 'Village Garage', 'is_supermarket' => false]);
$this->service->tag($station);
expect($station->is_supermarket)->toBeFalse();
});
it('handles case insensitive matching', function (): void {
$station = Station::factory()->make(['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 = Station::factory()->make([
'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');
});
- Step 2: Run tests to confirm they fail
php artisan test tests/Unit/Services/StationTaggingServiceTest.php
Expected: FAIL — StationTaggingService not found.
- Step 3: Implement StationTaggingService
<?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;
}
}
}
}
- Step 4: Run tests to confirm they pass
php artisan test tests/Unit/Services/StationTaggingServiceTest.php
Expected: 5 tests pass.
- Step 5: Commit
git add app/Services/StationTaggingService.php tests/Unit/Services/StationTaggingServiceTest.php
git commit -m "feat: add StationTaggingService with supermarket detection"
Task 7: FuelPriceService — OAuth token
Files:
-
Create:
app/Services/FuelPriceService.php -
Create:
tests/Unit/Services/FuelPriceServiceTest.php -
Step 1: Write failing test
<?php
use App\Services\FuelPriceService;
use App\Services\StationTaggingService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
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(); // no calls expected
$token = $this->service->getAccessToken();
expect($token)->toBe('cached-token');
Http::assertNothingSent();
});
- Step 2: Run tests to confirm they fail
php artisan test tests/Unit/Services/FuelPriceServiceTest.php
Expected: FAIL — FuelPriceService not found.
- Step 3: Implement FuelPriceService with token method
<?php
namespace App\Services;
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');
});
}
}
- Step 4: Run tests to confirm they pass
php artisan test tests/Unit/Services/FuelPriceServiceTest.php
Expected: 2 tests pass.
- Step 5: Commit
git add app/Services/FuelPriceService.php tests/Unit/Services/FuelPriceServiceTest.php
git commit -m "feat: FuelPriceService with OAuth token caching"
Task 8: FuelPriceService — station upsert
Files:
-
Modify:
app/Services/FuelPriceService.php -
Modify:
tests/Unit/Services/FuelPriceServiceTest.php -
Step 1: Write failing test
Add to tests/Unit/Services/FuelPriceServiceTest.php:
use App\Models\Station;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
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($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();
});
- Step 2: Run test to confirm it fails
php artisan test tests/Unit/Services/FuelPriceServiceTest.php --filter="upserts stations"
Expected: FAIL — upsertStations not found.
- Step 3: Implement upsertStations
Add to FuelPriceService:
use App\Models\Station;
use Illuminate\Support\Carbon;
/** @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: $rows,
uniqueBy: ['node_id'],
update: array_keys($rows[0] ?? []),
);
}
- Step 4: Run tests to confirm they pass
php artisan test tests/Unit/Services/FuelPriceServiceTest.php
Expected: 4 tests pass.
- Step 5: Commit
git add app/Services/FuelPriceService.php tests/Unit/Services/FuelPriceServiceTest.php
git commit -m "feat: FuelPriceService station upsert with tagging"
Task 9: FuelPriceService — price ingestion
Files:
-
Modify:
app/Services/FuelPriceService.php -
Modify:
tests/Unit/Services/FuelPriceServiceTest.php -
Step 1: Write failing tests
Add to tests/Unit/Services/FuelPriceServiceTest.php:
use App\Models\StationPrice;
use App\Models\StationPriceCurrent;
it('inserts new price row and upserts current when price changes', function (): void {
$station = Station::factory()->create(['node_id' => 'st001']);
// Existing current price
StationPriceCurrent::create([
'station_id' => 'st001',
'fuel_type' => 'e10',
'price_pence' => 14900, // 149.00p
'price_effective_at' => now()->subDay(),
'price_reported_at' => now()->subDay(),
'recorded_at' => now()->subDay(),
]);
$apiData = [[
'node_id' => 'st001',
'fuel_prices' => [[
'fuel_type' => 'E10',
'price' => 151.9, // changed price
'price_last_updated' => '2026-04-03T12:00:00.000Z',
'price_change_effective_timestamp' => '2026-04-03T11:00:00.000Z',
]],
]];
$this->service->processPrices($apiData);
expect(StationPrice::where('station_id', 'st001')->count())->toBe(1);
expect(StationPriceCurrent::where('station_id', 'st001')->where('fuel_type', 'e10')->first()->price_pence)
->toBe(15190);
});
it('skips insert when price has not changed', function (): void {
$station = Station::factory()->create(['node_id' => 'st002']);
StationPriceCurrent::create([
'station_id' => 'st002',
'fuel_type' => 'e10',
'price_pence' => 15190, // same as incoming
'price_effective_at' => now()->subDay(),
'price_reported_at' => now()->subDay(),
'recorded_at' => now()->subDay(),
]);
$apiData = [[
'node_id' => 'st002',
'fuel_prices' => [[
'fuel_type' => 'E10',
'price' => 151.9,
'price_last_updated' => '2026-04-03T12:00:00.000Z',
'price_change_effective_timestamp' => '2026-04-03T11:00:00.000Z',
]],
]];
$this->service->processPrices($apiData);
expect(StationPrice::where('station_id', 'st002')->count())->toBe(0);
});
it('inserts price for station with no existing current price', function (): void {
$station = Station::factory()->create(['node_id' => 'st003']);
$apiData = [[
'node_id' => 'st003',
'fuel_prices' => [[
'fuel_type' => 'E5',
'price' => 159.9,
'price_last_updated' => '2026-04-03T12:00:00.000Z',
'price_change_effective_timestamp' => '2026-04-03T11:00:00.000Z',
]],
]];
$this->service->processPrices($apiData);
expect(StationPrice::where('station_id', 'st003')->count())->toBe(1);
expect(StationPriceCurrent::where('station_id', 'st003')->where('fuel_type', 'e5')->first()->price_pence)
->toBe(15990);
});
- Step 2: Run tests to confirm they fail
php artisan test tests/Unit/Services/FuelPriceServiceTest.php --filter="price"
Expected: FAIL — processPrices not found.
- Step 3: Implement processPrices
Add to FuelPriceService:
use App\Enums\FuelType;
use App\Models\StationPrice;
use App\Models\StationPriceCurrent;
/** @param array<int, array<string, mixed>> $apiStations */
public function processPrices(array $apiStations): void
{
$now = now();
// Load current prices keyed by "station_id:fuel_type" for fast dedup lookup
$stationIds = array_column($apiStations, 'node_id');
$currentPrices = StationPriceCurrent::whereIn('station_id', $stationIds)
->get()
->keyBy(fn ($row) => $row->station_id.':'.$row->fuel_type->value);
$newHistoryRows = [];
$upsertCurrentRows = [];
foreach ($apiStations as $station) {
foreach ($station['fuel_prices'] ?? [] as $fp) {
$fuelType = FuelType::fromApiValue($fp['fuel_type']);
$pricePence = (int) round($fp['price'] * 100);
$key = $station['node_id'].':'.$fuelType->value;
// Skip if price unchanged
if (isset($currentPrices[$key]) && $currentPrices[$key]->price_pence === $pricePence) {
continue;
}
$row = [
'station_id' => $station['node_id'],
'fuel_type' => $fuelType->value,
'price_pence' => $pricePence,
'price_effective_at' => Carbon::parse($fp['price_change_effective_timestamp']),
'price_reported_at' => Carbon::parse($fp['price_last_updated']),
'recorded_at' => $now,
];
$newHistoryRows[] = $row;
$upsertCurrentRows[] = $row;
}
}
if ($newHistoryRows !== []) {
StationPrice::insert($newHistoryRows);
}
if ($upsertCurrentRows !== []) {
StationPriceCurrent::upsert(
rows: $upsertCurrentRows,
uniqueBy: ['station_id', 'fuel_type'],
update: ['price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'],
);
}
}
- Step 4: Run all service tests
php artisan test tests/Unit/Services/FuelPriceServiceTest.php
Expected: all tests pass.
- Step 5: Commit
git add app/Services/FuelPriceService.php tests/Unit/Services/FuelPriceServiceTest.php
git commit -m "feat: FuelPriceService price ingestion with deduplication"
Task 10: FuelPriceService — full and incremental poll methods
Files:
-
Modify:
app/Services/FuelPriceService.php -
Modify:
tests/Unit/Services/FuelPriceServiceTest.php -
Step 1: Write failing tests
Add to tests/Unit/Services/FuelPriceServiceTest.php:
use App\Events\PricesUpdatedEvent;
use Illuminate\Support\Facades\Event;
it('incremental poll processes prices from no-batch endpoint', function (): void {
Event::fake();
Station::factory()->create(['node_id' => 'st010']);
Http::fake([
'*/oauth/generate_access_token' => Http::response(['data' => ['access_token' => 'tok', 'expires_in' => 3600]]),
'*/pfs/fuel-prices' => Http::response([[
'node_id' => 'st010',
'fuel_prices' => [[
'fuel_type' => 'E10',
'price' => 149.9,
'price_last_updated' => '2026-04-03T12:00:00.000Z',
'price_change_effective_timestamp' => '2026-04-03T11:00:00.000Z',
]],
]]),
]);
$this->service->pollIncremental();
expect(StationPrice::count())->toBe(1);
Event::assertDispatched(PricesUpdatedEvent::class);
});
it('full poll iterates all batches and stops when fewer than 500 records returned', function (): void {
Event::fake();
Station::factory()->create(['node_id' => 'full001']);
$pfsStation = [[
'node_id' => 'full001',
'trading_name' => 'Test Garage',
'brand_name' => 'Test 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 Road',
'address_line_2' => null,
'city' => 'London',
'county' => null,
'country' => 'England',
'postcode' => 'E1 1AA',
'latitude' => 51.5,
'longitude' => -0.1,
],
'amenities' => [],
'opening_times'=> null,
'fuel_types' => ['E10'],
]];
Http::fake([
'*/oauth/generate_access_token' => Http::response(['data' => ['access_token' => 'tok', 'expires_in' => 3600]]),
'*/pfs?batch-number=1' => Http::response($pfsStation), // < 500 = last batch
'*/pfs/fuel-prices?batch-number=1' => Http::response([[
'node_id' => 'full001',
'fuel_prices' => [[
'fuel_type' => 'E10',
'price' => 149.9,
'price_last_updated' => '2026-04-03T12:00:00.000Z',
'price_change_effective_timestamp' => '2026-04-03T11:00:00.000Z',
]],
]]),
]);
$this->service->pollFull();
expect(Station::where('node_id', 'full001')->exists())->toBeTrue();
expect(StationPrice::count())->toBe(1);
Event::assertDispatched(PricesUpdatedEvent::class);
});
- Step 2: Run tests to confirm they fail
php artisan test tests/Unit/Services/FuelPriceServiceTest.php --filter="poll"
Expected: FAIL — PricesUpdatedEvent and poll methods not found.
- Step 3: Create PricesUpdatedEvent
<?php
namespace App\Events;
class PricesUpdatedEvent
{
public function __construct(
public readonly int $stationsProcessed,
public readonly int $priceChanges,
public readonly bool $isFullRefresh,
) {}
}
- Step 4: Implement pollIncremental and pollFull
Add to FuelPriceService:
use App\Events\PricesUpdatedEvent;
public function pollIncremental(): void
{
$token = $this->getAccessToken();
$response = Http::timeout(30)
->withToken($token)
->get(config('services.fuel_finder.base_url').'/pfs/fuel-prices');
$stations = $response->json() ?? [];
$this->processPrices($stations);
PricesUpdatedEvent::dispatch(
stationsProcessed: count($stations),
priceChanges: 0,
isFullRefresh: false,
);
}
public function pollFull(): void
{
$token = $this->getAccessToken();
$base = config('services.fuel_finder.base_url');
$batch = 1;
// Full station metadata refresh
do {
$stations = Http::timeout(30)
->withToken($token)
->get("{$base}/pfs", ['batch-number' => $batch])
->json() ?? [];
if ($stations !== []) {
$this->upsertStations($stations);
}
$batch++;
} while (count($stations) >= 500);
// Full price refresh
$batch = 1;
$totalPriceStations = 0;
do {
$priceStations = Http::timeout(30)
->withToken($token)
->get("{$base}/pfs/fuel-prices", ['batch-number' => $batch])
->json() ?? [];
if ($priceStations !== []) {
$this->processPrices($priceStations);
$totalPriceStations += count($priceStations);
}
$batch++;
} while (count($priceStations) >= 500);
PricesUpdatedEvent::dispatch(
stationsProcessed: $totalPriceStations,
priceChanges: 0,
isFullRefresh: true,
);
}
- Step 5: Run all service tests
php artisan test tests/Unit/Services/FuelPriceServiceTest.php
Expected: all tests pass.
- Step 6: Commit
git add app/Services/FuelPriceService.php app/Events/PricesUpdatedEvent.php tests/Unit/Services/FuelPriceServiceTest.php
git commit -m "feat: FuelPriceService incremental and full poll methods"
Task 11: PollFuelPricesCommand
Files:
-
Create:
app/Console/Commands/PollFuelPricesCommand.php -
Create:
tests/Feature/Commands/PollFuelPricesCommandTest.php -
Step 1: Write failing tests
<?php
use App\Services\FuelPriceService;
use App\Services\StationTaggingService;
it('runs incremental poll by default', function (): void {
$service = Mockery::mock(FuelPriceService::class);
$service->expects('pollIncremental')->once();
$service->shouldNotReceive('pollFull');
app()->instance(FuelPriceService::class, $service);
$this->artisan('fuel:poll')
->assertSuccessful();
});
it('runs full poll with --full flag', function (): void {
$service = Mockery::mock(FuelPriceService::class);
$service->expects('pollFull')->once();
$service->shouldNotReceive('pollIncremental');
app()->instance(FuelPriceService::class, $service);
$this->artisan('fuel:poll --full')
->assertSuccessful();
});
- Step 2: Run tests to confirm they fail
php artisan test tests/Feature/Commands/PollFuelPricesCommandTest.php
Expected: FAIL — command not found.
- Step 3: Implement PollFuelPricesCommand
<?php
namespace App\Console\Commands;
use App\Services\FuelPriceService;
use Illuminate\Console\Command;
class PollFuelPricesCommand extends Command
{
protected $signature = 'fuel:poll {--full : Run a full refresh of all stations and prices}';
protected $description = 'Poll the UK Fuel Finder API for price updates';
public function handle(FuelPriceService $service): int
{
if ($this->option('full')) {
$this->info('Running full refresh...');
$service->pollFull();
$this->info('Full refresh complete.');
} else {
$this->info('Running incremental poll...');
$service->pollIncremental();
$this->info('Incremental poll complete.');
}
return self::SUCCESS;
}
}
- Step 4: Run tests to confirm they pass
php artisan test tests/Feature/Commands/PollFuelPricesCommandTest.php
Expected: 2 tests pass.
- Step 5: Commit
git add app/Console/Commands/PollFuelPricesCommand.php tests/Feature/Commands/PollFuelPricesCommandTest.php
git commit -m "feat: add fuel:poll artisan command"
Task 12: ArchiveOldPricesCommand
Files:
-
Create:
app/Console/Commands/ArchiveOldPricesCommand.php -
Create:
tests/Feature/Commands/ArchiveOldPricesCommandTest.php -
Step 1: Write failing tests
<?php
use App\Models\Station;
use App\Models\StationPrice;
use App\Models\StationPriceArchive;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('moves prices older than 12 months to archive', function (): void {
$station = Station::factory()->create();
// Old price — should be archived
StationPrice::factory()->create([
'station_id' => $station->node_id,
'price_effective_at' => now()->subMonths(13),
'price_reported_at' => now()->subMonths(13),
'recorded_at' => now()->subMonths(13),
]);
// Recent price — should stay
StationPrice::factory()->create([
'station_id' => $station->node_id,
'price_effective_at' => now()->subMonths(6),
'price_reported_at' => now()->subMonths(6),
'recorded_at' => now()->subMonths(6),
]);
$this->artisan('fuel:archive')->assertSuccessful();
expect(StationPrice::count())->toBe(1);
expect(StationPriceArchive::count())->toBe(1);
});
it('does nothing when no old prices exist', function (): void {
Station::factory()->create();
$this->artisan('fuel:archive')
->expectsOutput('No prices to archive.')
->assertSuccessful();
});
- Step 2: Run tests to confirm they fail
php artisan test tests/Feature/Commands/ArchiveOldPricesCommandTest.php
Expected: FAIL — command not found.
- Step 3: Implement ArchiveOldPricesCommand
<?php
namespace App\Console\Commands;
use App\Models\StationPrice;
use App\Models\StationPriceArchive;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class ArchiveOldPricesCommand extends Command
{
protected $signature = 'fuel:archive';
protected $description = 'Move station price history older than 12 months to the archive table';
public function handle(): int
{
$cutoff = Carbon::now()->subMonths(12);
$count = StationPrice::where('price_effective_at', '<', $cutoff)->count();
if ($count === 0) {
$this->info('No prices to archive.');
return self::SUCCESS;
}
$this->info("Archiving {$count} price records older than {$cutoff->toDateString()}...");
// Move in chunks to avoid memory issues
StationPrice::where('price_effective_at', '<', $cutoff)
->chunkById(1000, function ($prices): void {
$rows = $prices->map->getAttributes()->toArray();
DB::transaction(function () use ($rows, $prices): void {
StationPriceArchive::insert($rows);
StationPrice::whereIn('id', $prices->pluck('id'))->delete();
});
});
$this->info('Archive complete.');
return self::SUCCESS;
}
}
- Step 4: Run tests to confirm they pass
php artisan test tests/Feature/Commands/ArchiveOldPricesCommandTest.php
Expected: 2 tests pass.
- Step 5: Commit
git add app/Console/Commands/ArchiveOldPricesCommand.php tests/Feature/Commands/ArchiveOldPricesCommandTest.php
git commit -m "feat: add fuel:archive command for price rotation"
Task 13: Register scheduler
Files:
-
Modify:
bootstrap/app.php -
Step 1: Register scheduled commands
In bootstrap/app.php, add ->withSchedule(...) after ->withMiddleware(...):
use App\Console\Commands\ArchiveOldPricesCommand;
use App\Console\Commands\PollFuelPricesCommand;
use Illuminate\Console\Scheduling\Schedule;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withSchedule(function (Schedule $schedule): void {
$schedule->command(PollFuelPricesCommand::class)
->everyFifteenMinutes()
->withoutOverlapping()
->runInBackground();
$schedule->command(PollFuelPricesCommand::class, ['--full'])
->dailyAt('03:00')
->withoutOverlapping();
$schedule->command(ArchiveOldPricesCommand::class)
->monthlyOn(1, '04:00');
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();
- Step 2: Verify scheduler shows commands
php artisan schedule:list
Expected: fuel:poll (every 15 min), fuel:poll --full (daily 3am), fuel:archive (monthly) listed.
- Step 3: Run full test suite
php artisan test
Expected: all tests pass.
- Step 4: Commit
git add bootstrap/app.php
git commit -m "feat: register fuel polling and archive commands in scheduler"
Self-Review
Spec coverage check:
- ✅ OAuth token fetch + cache (
fuel_finder_access_token, TTL 3540s) - ✅ Token refresh endpoint noted in api-data.md (used in
getAccessTokenon cache miss) - ✅ Station upsert from
/pfswith all API fields - ✅
StationTaggingServicebrand detection - ✅ Price deduplication via
station_prices_current - ✅ Both timestamps stored (
price_effective_at,price_reported_at) - ✅
station_pricespartitioned monthly, composite PK - ✅
station_prices_archiveidentical schema, no partition - ✅ Incremental poll (every 15 min) + full poll (daily)
- ✅
PricesUpdatedEventdispatched after each poll - ✅
fuel:archivecommand with chunked move - ✅ Scheduler registration with
withoutOverlapping()
No placeholders found.
Type consistency confirmed — FuelType::fromApiValue() used consistently in processPrices, fuel_type stored as enum value (string) in DB rows.