Vehicle Manager Documentation
Welcome to the official documentation for Vehicle Manager - a comprehensive, self-hosted platform for tracking every aspect of vehicle ownership. Whether you manage a single car or a whole fleet of motorcycles, vans, and trucks, this guide will walk you through everything from initial setup to the deepest corners of the API.
Introduction
Vehicle Manager was built with a simple philosophy: you should own your data. There are no cloud subscriptions, no third-party analytics, and no telemetry. You host it yourself, and everything - every fuel receipt, every MOT advisory, every depreciation calculation - lives on your own infrastructure.
The platform covers fuel costs, service history, MOT records, insurance policies, road tax, parts, consumables, vehicle specifications, depreciation tracking, receipt OCR, report generation, and much more. It's built as three interlocking components:
Backend API
Symfony 6.4 · PHP 8.4 · MySQL 8 · Redis 7
Web Frontend
React 18 · MUI 5 · Charts · i18n (21 languages)
Mobile App
React Native · TypeScript · Offline-first · 20 languages
Everything is open-source, free forever, and licensed permissively. The project lives on GitHub and contributions are always welcome.
Prerequisites
Before you begin, make sure your machine has the following installed. If you're deploying to a server, these same requirements apply there.
| Requirement | Minimum Version | Notes |
|---|---|---|
| Docker | 20.10+ | Docker Desktop or Docker Engine |
| Docker Compose | 1.29+ or v2 | Bundled with Docker Desktop; standalone works too |
| Git | 2.30+ | For cloning the repository |
| RAM | 4 GB free | MySQL, Redis, PHP-FPM, Nginx, and Node all run simultaneously |
| Disk | 2 GB free | Docker images plus room for uploads and database growth |
If you plan to work on the React frontend outside of Docker, you'll also need Node.js 18+ and npm or yarn installed locally.
Docker Setup
The entire application runs inside Docker containers. Clone the repository, bring the containers up, and you're ready to go - no manual PHP, MySQL, or Redis installation required.
1. Clone the Repository
git clone https://github.com/peteclarke-del/vehicle.git
cd vehicle
2. Start the Containers
docker-compose up -d
This spins up five services. Here's what each one does and where it listens:
| Service | Image | Port | Purpose |
|---|---|---|---|
| mysql | mysql:8.0 | 3306 | Primary database - 512 MB memory limit, health-checked |
| redis | redis:7-alpine | 6379 | Cache layer - 256 MB maxmemory, AOF persistence, noeviction policy |
| php | Custom PHP 8.4 FPM | 9000 (internal) | Application server - Xdebug, Composer, 200 MB upload limit, 1 GB memory |
| nginx | Custom Nginx | 8081 | Reverse proxy to PHP-FPM - serves the API |
| frontend | Custom Node | 3000 | React dev server - hot-reload, 2 GB memory limit |
3. Wait for Readiness
MySQL has a health check configured. The PHP entrypoint script waits for the database to be ready and then automatically runs Doctrine migrations. You can follow the logs to watch for completion:
docker-compose logs -f php
Once you see the FPM "ready to handle connections" message, you're good.
4. Environment Variables
The default .env file ships with sensible development defaults. For production, you'll want to
configure these key variables:
| Variable | Purpose |
|---|---|
DATABASE_URL | MySQL connection string |
REDIS_URL | Redis connection string |
JWT_SECRET_KEY / JWT_PUBLIC_KEY / JWT_PASSPHRASE | JWT signing keys |
APP_SECRET | Symfony application secret |
CORS_ALLOW_ORIGIN | Allowed CORS origins (production only) |
DVLA_API_KEY / DVLA_CLIENT_ID / DVLA_CLIENT_SECRET | UK DVLA vehicle lookup |
DVSA_API_KEY / DVSA_CLIENT_ID / DVSA_CLIENT_SECRET | UK DVSA MOT history |
API_NINJAS_KEY | VIN decoding and vehicle specifications |
EBAY_CLIENT_ID / EBAY_CLIENT_SECRET | eBay Browse API for part scraping |
First Run
Once the containers are up and MySQL migrations have completed, you need to seed the reference data that powers the application's dropdowns and category systems.
Seed Lookup Data
docker-compose exec php bin/console doctrine:fixtures:load --no-interaction
This populates vehicle types (Motorcycle, Car, Van, Truck, EV), makes, models, consumable types, part categories,
and security features from the bundled JSON data files in backend/data/.
Seed Feature Flags
docker-compose exec php bin/console app:seed-feature-flags
This creates the 49 default feature flags that control which parts of the application are available to each user. By default, all flags are enabled for everyone. Admins can override them per-user later.
Access the Application
| Interface | URL |
|---|---|
| Web Frontend | http://localhost:3000 |
| Backend API | http://localhost:8081/api |
| Health Check | http://localhost:8081/health |
Creating an Account
Head to the web frontend at http://localhost:3000 and click Register. You'll need
to provide your name, email address, and a password. The password must meet the security policy (minimum 8 characters
with a mix of upper-case, lower-case, digits, and special characters).
Alternatively, you can register via the API directly:
curl -X POST http://localhost:8081/api/register \
-H "Content-Type: application/json" \
-d '{
"email": "you@example.com",
"password": "SecureP@ss1",
"firstName": "Jane",
"lastName": "Doe"
}'
Becoming an Admin
The first user you create will have standard ROLE_USER permissions. To promote yourself to an
administrator - which unlocks user management, feature flag control, and vehicle assignment features - run
this SQL against your database:
UPDATE users SET roles = '["ROLE_USER","ROLE_ADMIN"]' WHERE id = 1;
Admins can see all vehicles across all users, manage feature flags, create and disable user accounts, force password changes, and assign vehicles between users. Grant this role carefully.
Architecture Overview
Vehicle Manager follows a clean API-first architecture. The backend exposes a RESTful JSON API that both the web and mobile front-ends consume identically. There is no server-side rendering - the backend is purely an API server, and the front-ends are fully decoupled single-page applications.
JWT Auth
Stateless tokens · 1-hour TTL · Refresh tokens · Header & query extraction
Redis Cache
Tag-based invalidation · 5 named pools · 2 min–1 hr TTLs
Doctrine ORM
27 entities · MySQL 8 · Migrations · Fixtures
Multi-platform
Web + Mobile · Shared API · Offline-capable
Authentication uses the Lexik JWT Authentication Bundle. Tokens have a one-hour time-to-live
and can be extracted from both the Authorization: Bearer header and a ?token= query
parameter (the latter is used for SSE notification streams). Refresh tokens are stored in the database with a
30-day expiry, and users can explicitly revoke them.
Caching runs through Redis 7 with tag-based invalidation. Five named cache pools cover different data types, each with a TTL tuned for how frequently the underlying data changes:
| Cache Pool | TTL | Used For |
|---|---|---|
cache.vehicles | 10 minutes | Vehicle lists and detail responses |
cache.dashboard | 2 minutes | Dashboard totals, cost breakdowns, stats |
cache.preferences | 30 minutes | User preferences |
cache.records | 5 minutes | Fuel, service, MOT, and other record lists |
cache.lookups | 1 hour | Vehicle types, makes, models, categories |
Backend Architecture
The backend is a Symfony 6.4 application running on PHP 8.4. It uses attribute-based routing on controller methods, Doctrine ORM for database access, and Symfony's autowiring container for dependency injection. The codebase is organised into these key layers:
- Controllers (32 files) - Handle HTTP requests, validate input, enforce authorisation, and return JSON responses. Seven reusable traits provide common functionality like ownership verification, JSON validation, and entity hydration.
- Services (16+ files) - Encapsulate business logic: cost calculation, depreciation, OCR, report generation, URL scraping (with adapters for eBay, Amazon, Shopify, and generic sites), VIN decoding, DVLA/DVSA integration, and import/export.
- Entities (27 files) - Doctrine ORM entities mapping to MySQL tables. Rich relationships: User → Vehicles → all sub-records. Vehicle → Specification (OneToOne). InsurancePolicy ↔ Vehicles (ManyToMany).
- Event Subscribers (3 files) - FeatureFlagSubscriber enforces per-user feature flags on API routes. TestAuthSubscriber enables mock authentication in test environments. PreventFixturesLoadSubscriber blocks fixture loading in production.
- Commands (5 files) - Console commands for seeding feature flags, importing consumables/parts, migrating attachments, and normalising MOT advisories.
- Data Fixtures (8 files) - Seed lookup data from bundled JSON files covering vehicle types, makes, models, consumable types, part categories, and security features.
Controller Traits
Seven traits keep controllers DRY:
| Trait | Purpose |
|---|---|
AuthenticationRequiredTrait | Provides getUserEntity() and admin-check helpers |
JsonValidationTrait | Parses and validates JSON request bodies |
OwnershipVerificationTrait | Verifies the current user owns or is assigned to the vehicle/entity |
EntityHydrationTrait | Populates entity fields from request data |
DateSerializationTrait | Consistent date formatting across responses |
ReceiptAttachmentTrait | Links receipt attachments to records |
UserSecurityTrait | Role-based access checks (isAdminForUser()) |
Frontend Architecture
The web frontend is a React 18 single-page application bootstrapped with Create React App. It uses MUI 5 (Material UI) for the component library, React Router v6 for client-side routing, and Axios for HTTP communication with the backend.
The app is wrapped in a layered provider hierarchy: ThemeContext (MUI light/dark theming) →
AuthContext (JWT token management, login/logout, user state) → UserPreferencesContext
(default vehicle, distance unit, currency, rows per page) → VehicleContext (global vehicle list
with 30-second caching) → PermissionsContext (feature-flag and assignment-based access control).
The main layout is an AppBar with a collapsible drawer. Navigation items in the drawer are
drag-and-drop reorderable (via @dnd-kit) and their order is persisted to user
preferences. The drawer can be pinned open or collapsed to a mini-rail (48 px icons only).
Charts are powered by @mui/x-charts - PieChart for cost breakdowns, BarChart for monthly spend,
and LineChart for depreciation curves. Reports use 13 JSON template definitions and can be previewed in-browser
via SheetJS (XLSX) or downloaded as PDF.
Mobile Architecture
The mobile app is built with React Native 0.73 and TypeScript. It uses React Native Paper (Material Design 3) for the UI layer and React Navigation for stack and tab-based navigation.
The defining feature of the mobile app is its dual-mode architecture. On first launch, the user
is presented with a choice: connect to a remote Vehicle Manager server (web mode) or use the app entirely offline
(standalone mode). In standalone mode, a LocalApiAdapter provides full CRUD operations backed by
AsyncStorage, so the app works without any server at all.
In web mode, a SyncContext manages an offline change queue. When the device loses connectivity,
mutations are queued in AsyncStorage and automatically replayed when the connection returns - with a 2-second
debounce, up to 5 retries per operation, and automatic discard of 4xx errors (which indicate permanently invalid
data). The useOfflineData hook provides cache-first loading with a 24-hour TTL, so the app always
shows data even when offline.
Database Schema
The database is MySQL 8.0 with 27 Doctrine ORM entities. The central entity is Vehicle, which has cascade-remove relationships to almost everything else - deleting a vehicle automatically removes all of its fuel records, service records, parts, consumables, MOT records, road tax, todos, images, status history, and specification.
Key relationships to understand:
- User → Vehicles - A user owns many vehicles (OneToMany)
- Vehicle → Sub-records - Each vehicle has many fuel records, service records, parts, consumables, MOT records, road tax records, todos, images, and status history entries (all OneToMany with cascade remove)
- Vehicle → Specification - Each vehicle has at most one specification entity (OneToOne with cascade remove), covering ~40 fields for engine, drivetrain, chassis, and dimensions
- InsurancePolicy ↔ Vehicles - Policies can cover multiple vehicles, and a vehicle can be on multiple policies (ManyToMany via a join table)
- ServiceRecord → ServiceItems - A service record contains multiple line items, each of which may link to a Part or Consumable
- VehicleAssignment - Allows sharing vehicles between users with granular permissions (canView, canEdit, canAddRecords, canDelete)
- FeatureFlag ↔ UserFeatureOverride ↔ User - 49 feature flags with per-user overrides
Authentication API
All authenticated endpoints require a Bearer token in the Authorization header.
Tokens are issued on login, last one hour, and can be refreshed using a long-lived refresh token (30 days).
The authentication flow is entirely stateless - there are no server-side sessions.
Submit your email and password to receive a JWT access token. This endpoint is handled by Symfony's JSON login firewall.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Your registered email address |
password | string | Yes | Your password |
Response (200)
{ "token": "eyJ0eXAiOiJKV1Qi..." }
Errors
401 - Invalid credentials.
Register a new account. The user is automatically assigned ROLE_USER and default preferences are created. Country defaults to GB if not provided.
Request Body
| Field | Type | Required |
|---|---|---|
email | string | Yes |
password | string | Yes |
firstName | string | Yes |
lastName | string | Yes |
country | string (2-letter code) | No (defaults to GB) |
Response (201)
{ "message": "User registered successfully", "userId": 1 }
Returns the authenticated user's profile information along with their resolved feature flags and any vehicle assignments they have.
Response (200)
{
"id": 1,
"email": "you@example.com",
"firstName": "Jane",
"lastName": "Doe",
"roles": ["ROLE_USER"],
"country": "GB",
"isActive": true,
"isVerified": false,
"passwordChangeRequired": false,
"features": { "dashboard": true, "fuel_records": true, ... },
"vehicleAssignments": [ ... ]
}
Update your first name, last name, or email address.
Request Body
| Field | Type |
|---|---|
firstName | string |
lastName | string |
email | string |
Generates a long-lived refresh token (30 days) that can be used to obtain new JWT access tokens without re-authenticating. Requires IS_AUTHENTICATED_FULLY.
Response (200)
{ "refreshToken": "a1b2c3d4e5..." }
Exchange a valid refresh token for a new JWT access token. This is a public endpoint - no Bearer token required.
Request Body
{ "refreshToken": "a1b2c3d4e5..." }
Response (200)
{ "token": "eyJ0eXAiOiJKV1Qi..." }
Revokes one or all refresh tokens for the authenticated user. Used during logout to invalidate sessions.
Change your password. You must provide your current password for verification. The new password must meet the security policy.
Request Body
{ "currentPassword": "OldP@ss1", "newPassword": "NewP@ss2" }
Sets the passwordChangeRequired flag on the target user. On their next login, they will be forced to set a new password before they can do anything else. Requires ROLE_ADMIN.
Admin API
Every endpoint in this section requires ROLE_ADMIN. Administrators can manage user accounts,
control feature flags on a per-user basis, and assign vehicles between users with granular permissions.
Returns every registered user along with summary statistics (vehicle count, last login, active status).
Create a user account on their behalf. Fields: email, password, firstName, lastName, and optionally roles.
Returns full details for a specific user, including their roles, active status, vehicle count, and last login time.
Replace a user's role array. Valid roles are ROLE_USER and ROLE_ADMIN.
Request Body
{ "roles": ["ROLE_USER", "ROLE_ADMIN"] }
Toggles the user's isActive flag. Disabled users cannot authenticate.
Sets passwordChangeRequired = true. The user will be prompted to set a new password on their next session.
Returns all 49 feature flags organised by category (e.g., records, admin, vehicles, reports). Each flag includes its key, label, description, default state, and sort order.
Returns the effective feature flags for a specific user - the result of merging default flag values with any per-user overrides.
Replace all feature overrides for a user. Send an object mapping feature keys to boolean values.
Request Body
{ "overrides": { "fuel_records": true, "mot_records": false, "reports": true } }
Removes all per-user feature overrides, reverting the user to the system-wide default flag values.
Returns all vehicles assigned to this user (that they don't own) along with their permission levels.
Completely replaces the user's vehicle assignments. Each assignment specifies a vehicle and granular permissions.
Request Body
{ "assignments": [
{ "vehicleId": 5, "canView": true, "canEdit": true, "canAddRecords": true, "canDelete": false }
] }
Revokes all vehicle assignments for this user. They will only be able to see their own vehicles.
Creates or updates the 49 default feature flags in the database. Safe to run multiple times - existing flags are updated, not duplicated.
Vehicles API
The vehicle endpoints form the core of the application. Vehicles are the top-level entity that everything else hangs off - fuel records, services, parts, MOT history, and more. Admin users see all vehicles across all users; regular users see only their own vehicles plus any that have been assigned to them.
Returns all vehicles the authenticated user has access to. Results are cached for 10 minutes per user and automatically invalidated when vehicles are created, updated, or deleted. Admins see every vehicle in the system; regular users see vehicles they own plus any assigned to them.
Response
An array of vehicle objects, each including nested owner info, vehicle type, image URLs, computed fields (current mileage, MOT expiry, road tax expiry, insurance expiry, age, classic status), and relationship counts.
Returns full details for one vehicle including all computed fields, specification summary, and image list.
Create a new vehicle. The authenticated user is set as the owner. Vehicle type is required. Registration number and VIN are optional but recommended - they're used for DVLA/DVSA lookups and attachment file organisation.
Request Body
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | Yes | Display name (e.g. "My Ford Focus") |
vehicleType | int | Yes | Vehicle type ID (1=Motorcycle, 2=Car, etc.) |
make | string | No | Manufacturer |
model | string | No | Model name |
year | int | No | Year of manufacture |
registrationNumber | string | No | UK or other registration plate |
vin | string | No | 17-character VIN |
purchaseCost | decimal | No | Purchase price |
purchaseDate | date | No | YYYY-MM-DD |
purchaseMileage | int | No | Odometer at purchase |
vehicleColor | string | No | |
status | string | No | Live, Sold, Scrapped, or Exported (default: Live) |
depreciationMethod | string | No | straight_line, declining_balance, double_declining, or automotive_standard |
depreciationYears | int | No | Default: 10 |
depreciationRate | decimal | No | Default: 20.00 |
motExempt | bool | No | Override; auto-set for vehicles ≥30 years old |
roadTaxExempt | bool | No | Override; auto-set for vehicles ≥30 years old |
securityFeatures | string | No | Comma-separated or text description |
Update any vehicle fields. If the status changes (e.g., from Live to Sold), a VehicleStatusHistory record is automatically created tracking the old status, new status, change date, and any notes.
Permanently deletes the vehicle and all associated records (fuel, service, parts, consumables, MOT, road tax, todos, images, specification, status history). This is a cascade delete and cannot be undone.
Response
204 No Content
Returns a year-by-year depreciation schedule for the vehicle. The schedule shows the value at the start of each year, the depreciation amount for that year, and the cumulative depreciation. Cached for 1 hour. Supports four methods:
- automotive_standard (default) - 20% in year one, then 15% per year thereafter
- straight_line - Equal depreciation each year (purchase cost ÷ years)
- declining_balance - Fixed percentage of the remaining value each year
- double_declining - Accelerated: double the straight-line rate applied to remaining value
Returns a detailed breakdown of all costs associated with the vehicle, organised by category: fuel, parts, consumables, service, insurance, road tax, MOT, and depreciation. Cached for 2 minutes.
Returns comprehensive statistics: total costs by category, cost per mile, average fuel consumption (MPG and L/100km), total mileage driven, and more. Cached for 2 minutes.
Returns monthly fuel and maintenance costs per vehicle over a configurable period. This powers the dashboard bar charts. Also includes vehicleTotals - the total cost per vehicle across the period - used for the pie chart.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
months | int | 6 | Number of months to look back (max 60) |
Returns aggregated totals across all the user's vehicles for the dashboard stat cards: total fuel spend, total parts, total consumables, total service costs, total insurance, total road tax, total MOT costs, and overall total spend.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
period | int | 12 | Number of months to aggregate |
Fuel Records API
Fuel records track every fill-up: date, litres, cost, mileage, fuel type, and station. The system automatically calculates MPG, price per litre, and cost per mile from the raw data. You can optionally attach a receipt photograph for OCR extraction.
Returns a static list of fuel types: Petrol, Diesel, Premium Petrol, Premium Diesel, E10, E85, LPG, CNG, Hydrogen, Electric.
Returns fuel records, optionally filtered by vehicle.
Query Parameters
| Param | Type | Description |
|---|---|---|
vehicleId | int | Filter by vehicle (optional) |
Returns one fuel record with all computed fields (MPG, price per litre, cost per mile).
Request Body
| Field | Type | Required |
|---|---|---|
vehicleId | int | Yes |
date | string (YYYY-MM-DD) | Yes |
litres | decimal | Yes |
cost | decimal | Yes |
mileage | int | Yes |
fuelType | string | No |
station | string | No |
notes | string | No |
Update any field on an existing fuel record.
Permanently removes the fuel record. Returns 204.
Each fuel record automatically computes: calculateMpg() (miles per gallon from mileage difference and litres), getPricePerLitre(), calculateCostPerMile(), and convertMpgToLitres100km() for metric users.
Service Records API
Service records capture maintenance work - oil changes, brake replacements, tyre fitting, and anything else done to the vehicle. Each record can have multiple service items, each typed as a part, consumable, or labour charge. Items can be linked to existing Part or Consumable entities.
Query Parameters
| Param | Type | Description |
|---|---|---|
vehicleId | int | Filter by vehicle |
unassociated | bool | Show only records not linked to a vehicle |
Returns the service record and its nested items array.
Request Body
| Field | Type | Required |
|---|---|---|
vehicleId | int | Yes |
serviceDate | date | Yes |
serviceType | string | Yes |
laborCost | decimal | No |
partsCost | decimal | No |
mileage | int | No |
serviceProvider | string | No |
workPerformed | string | No |
nextServiceDate | date | No |
nextServiceMileage | int | No |
notes | string | No |
items | array | No |
Each item in the items array has: type (part/labour/consumable), description, cost, quantity, and optionally consumableId or partId to link to an existing entity.
Updates the record and its items transactionally. Existing items can be updated or removed, and new items can be added in the same request.
Deletes the service record and all its items. Returns 204.
Returns just the items for a specific service record, without the parent record.
Update one item's type, description, cost, quantity, or linked entity.
Query Parameters
| Param | Type | Description |
|---|---|---|
removeLinked | bool | If true, also deletes the linked Part or Consumable entity |
Parts API
Parts represent physical components purchased for a vehicle - brake pads, filters, spark plugs, bulbs, and so on. Each part can be linked to a service record, MOT record, or todo. The URL scraper endpoint lets you pull product details directly from online retailers.
Query Parameters
| Param | Type | Description |
|---|---|---|
vehicleId | int | Filter by vehicle |
unassociated | bool | Show only parts not linked to a vehicle |
Returns part details with linked records and receipt attachment info.
Request Body
| Field | Type | Required |
|---|---|---|
vehicleId | int | Yes |
name / description | string | Yes |
partNumber | string | No |
manufacturer | string | No |
supplier | string | No |
cost | decimal | Yes |
quantity | int | No (default 1) |
purchaseDate | date | Yes |
partCategory | int | No |
warrantyMonths | int | No |
installationDate | date | No |
mileageAtInstallation | int | No |
productUrl | string | No |
notes | string | No |
includedInServiceCost | bool | No |
Update any field on an existing part.
Permanently removes the part record and any linked receipt attachment. Returns 204.
Send a product URL and the system will attempt to extract the product name, price, description, images, and part number. Uses an adapter pipeline: eBay Browse API → Shopify → Amazon → eBay HTML → Generic DOM/OpenGraph/JSON-LD.
Request Body
{ "url": "https://www.ebay.co.uk/itm/123456789" }
Response
{
"name": "NGK Spark Plug BKR6E-11",
"price": 4.99,
"description": "...",
"imageUrl": "https://...",
"partNumber": "BKR6E-11"
}
Consumables API
Consumables are items that need regular replacement - engine oil, coolant, brake fluid, tyres, chain lubricant, and so on. Unlike parts, consumables have replacement tracking: you can record the replacement interval (in miles), when it was last changed, and the mileage at change. The system uses this data to trigger notifications when a consumable is due for replacement.
Query Parameters
| Param | Type | Description |
|---|---|---|
vehicleId | int | Filter by vehicle |
unassociated | bool | Show unlinked consumables |
Returns consumable details with replacement tracking status.
Key Fields
| Field | Type | Required |
|---|---|---|
vehicleId | int | Yes |
consumableType | int | Yes |
description | string | No |
brand | string | No |
partNumber | string | No |
cost | decimal | No |
quantity | decimal | No |
supplier | string | No |
replacementInterval | int | No |
lastChanged | date | No |
mileageAtChange | int | No |
productUrl | string | No |
includedInServiceCost | bool | No |
Update any consumable field, including replacement tracking data.
Returns 204.
Identical to the parts scraper - extracts product name, price, and description from a retailer URL.
MOT Records API
MOT records track the annual roadworthiness test required for vehicles in the UK. Each record captures the test result (Pass, Fail, or Advisory), costs, mileage, structured advisory and failure items, and repair details. The system integrates with the DVSA API to automatically import historical MOT data.
Query Parameters
| Param | Type |
|---|---|
vehicleId | int |
Returns the full MOT record including parsed advisories and failures.
Request Body
| Field | Type | Required |
|---|---|---|
vehicleId | int | Yes |
testDate | date | Yes |
result | string | Yes |
testCost | decimal | Yes |
repairCost | decimal | No |
mileage | int | No |
testCenter | string | No |
expiryDate | date | No |
motTestNumber | string | No |
testerName | string | No |
isRetest | bool | No |
advisories | text | No |
failures | text | No |
repairDetails | text | No |
notes | text | No |
Update any field on an existing MOT record.
Returns 204.
Returns all parts, consumables, and service records that are linked to this specific MOT record - useful for seeing what work was done to pass the test.
Queries the UK DVSA API to retrieve the full MOT test history for a vehicle registration. Returns test dates, results, mileage, advisories, and failures. Does not create records - use the import endpoint for that.
Query Parameters
| Param | Type |
|---|---|
registration | string (e.g. AB12CDE) |
Fetches MOT history from DVSA and creates MOT records for the specified vehicle. Skips any tests that have already been imported (matched by test number).
Request Body
{ "vehicleId": 1, "registration": "AB12CDE" }
Insurance API
Insurance is modelled with two levels. Insurance policies represent the actual policy documents from providers - these can cover multiple vehicles via a ManyToMany relationship. There are also vehicle-specific insurance records for simpler per-vehicle tracking. Both approaches are supported and you can use whichever fits your situation.
Policy Endpoints
Returns all insurance policies for the authenticated user, with their attached vehicles.
Key Fields
| Field | Type |
|---|---|
provider | string (required) |
policyNumber | string |
annualCost | decimal |
startDate / expiryDate | date |
coverageType | string (Third Party, Third Party Fire & Theft, Comprehensive) |
excess | decimal |
ncdYears | int (no-claims discount years) |
mileageLimit | int |
autoRenewal | bool |
notes | string |
Returns policy details with all attached vehicles.
Update any policy field.
Returns 204. Vehicle associations are removed but the vehicles themselves are not affected.
{ "vehicleId": 5 }
Removes the vehicle from this policy without deleting either entity.
Vehicle Insurance Endpoints
Filter by ?vehicleId=N for per-vehicle records.
Simpler per-vehicle insurance entry for cases where a full policy model is overkill.
Road Tax API
Road tax records track vehicle excise duty (VED) payments. The system knows that vehicles aged 30 years or older are automatically road tax exempt in the UK, and will reject attempts to create road tax records for exempt vehicles. SORN (Statutory Off Road Notification) is also supported.
Filter by ?vehicleId=N.
Key Fields
| Field | Type |
|---|---|
vehicleId | int (required) |
startDate | date |
expiryDate | date |
amount | decimal |
frequency | string (annual, 6_month - default: annual) |
sorn | bool (default: false) |
notes | string |
If the vehicle is 30+ years old (or has roadTaxExempt set to true), the API will return 400 and refuse to create the record.
Update dates, amount, frequency, SORN status, or notes.
Returns 204.
Todos API
Todos let you track upcoming work for a vehicle - "replace front tyre", "book MOT", "order new battery". Each todo can be linked to parts and consumables, and when you mark a todo as done, the completion timestamp is recorded. Overdue todos trigger notifications.
Filter by ?vehicleId=N. Requires ROLE_USER.
Request Body
| Field | Type | Required |
|---|---|---|
vehicleId | int | Yes |
title | string | Yes |
description | string | No |
dueDate | datetime | No |
Setting done: true automatically records the completedBy timestamp. Linked parts and consumables can also cascade their completion state.
Returns 204.
Attachments API
Attachments handle file uploads - primarily receipt photographs for OCR, but also general documentation,
user manuals, and service manuals. Files are organised on disk into
uploads/vehicles/{registration}/{category}/ for clean file management. The system validates
MIME types and enforces a configurable size limit (default 200 MB).
When an admin user uploads an attachment for a vehicle they don't own, the attachment is automatically attributed to the vehicle's owner - not the admin.
Multipart form-data upload. Attach a file to a vehicle and optionally to a specific entity.
Form Fields
| Field | Type | Required |
|---|---|---|
file | file | Yes |
vehicleId | int | No |
entityType | string | No |
entityId | int | No |
category | string | No |
description | string | No |
Runs Tesseract OCR on the attachment image and returns structured data extracted from the receipt. The type parameter tells the engine what to look for.
Query Parameters
| Param | Values | Extracted Fields |
|---|---|---|
type=fuel | fuel | date, cost, litres, station, fuelType |
type=part | part | name, partNumber, price, quantity, supplier |
type=consumable | consumable | name, price, quantity, supplier |
type=service | service | description, laborCost, partsCost, date |
Filter by entityType, entityId, and/or category.
By default, streams the file for download. Add ?metadata=true to get JSON metadata instead (filename, MIME type, size, upload date).
Update the description, category, or entity association.
Deletes both the database record and the file from disk. Returns 204.
Reports API
The reporting system is template-driven. There are 13 built-in report templates covering fuel costs, service costs, part costs, consumable tracking, insurance and MOT expiry, road tax, and comprehensive vehicle reports. Reports can be downloaded as XLSX, PDF, or CSV.
Returns previously generated reports for the authenticated user.
Create a report from a template. Available template keys:
| Template Key | Description |
|---|---|
fuel_costs | Fuel expenditure breakdown |
service_costs | Service and maintenance costs |
part_costs | Parts expenditure |
consumables_total | Total consumable spend |
consumables_due | Consumables due for replacement |
total_expenditure | Complete cost summary across all categories |
vehicle_report | Full single-vehicle report |
expired_mot | Vehicles with expired or expiring MOT |
expired_insurance | Expired or expiring insurance policies |
expired_road_tax | Expired road tax |
service_due | Vehicles with upcoming service dates |
insurance_due | Insurance renewals due |
road_tax_due | Road tax renewals due |
Query Parameters
| Param | Values |
|---|---|
format | xlsx, pdf, csv |
Notifications API
The notification system checks for items that need attention - MOT tests expiring soon, road tax renewals, overdue service dates, insurance expiry, consumables due for replacement, and todo deadlines. The web app uses Server-Sent Events (SSE) for real-time updates, with a fallback to polling.
Returns an array of notification objects, each with a type (mot, tax, service, insurance, consumable, todo), severity (info, warning, danger), vehicle info, and a human-readable message.
Opens a Server-Sent Events stream that pushes notifications to the client in real time. The JWT token is passed as a query parameter since SSE doesn't support custom headers.
Query Parameters
| Param | Type |
|---|---|
token | string (JWT token) |
Lookups API
Lookup endpoints provide the reference data that populates dropdowns and selection lists throughout the application. All lookup data is cached for one hour. Vehicle types, makes, models, part categories, consumable types, and security features are seeded from the bundled JSON fixtures.
Returns: Motorcycle, Car, Van, Truck, EV. Cached 1 hour.
Returns consumable types relevant to the given vehicle type (e.g., motorcycles have chain lube; cars don't). Cached 1 hour.
Filter by ?vehicleTypeId=N. Cached 1 hour. You can also POST to create a custom make.
Filter by ?makeId=N and optionally &year=YYYY. Cached 1 hour. You can also POST to create a custom model.
Filter by ?vehicleTypeId=N. Cached 1 hour.
Filter by ?vehicleTypeId=N. Returns features like Disc Lock, Alarm, Tracker, Immobiliser. Cached 1 hour.
External API Integrations
Vehicle Manager integrates with several external APIs for UK vehicle data and product scraping. These require API keys configured via environment variables. All external calls include retry logic and graceful error handling.
DVLA - Vehicle Registration Lookup
Queries the UK DVLA API and returns vehicle information: make, model, colour, fuel type, engine size, year of manufacture, tax status, and MOT status. Uses OAuth2 client-credentials flow with exponential backoff retry on 429 (rate limit) and 504 (timeout) responses. Results are cached.
DVSA - MOT History
Returns vehicle details from the DVSA database.
Returns every MOT test ever conducted on the vehicle - dates, results, mileage, advisories, and failure items.
Returns just the most recent MOT test result for quick status checks.
Verifies that the DVSA API keys are configured and the service is reachable.
VIN Decoding
VIN decoding is available via GET /api/vehicles/{id}/vin-decode (documented under Vehicles API). It uses the API Ninjas service to decode 17-character VINs into make, model, year, country, and vehicle class.
Vehicle Specifications Scraping
Specification scraping is available via POST /api/vehicles/{id}/specifications/scrape (documented under Vehicles API). It uses an adapter pipeline with DVLA, API Ninjas Motorcycles, and API Ninjas Cars adapters.
Import & Export API
The import/export system lets you back up your entire vehicle collection and restore it elsewhere. Exports include all sub-records (fuel, service, parts, consumables, MOT, insurance, road tax, todos, specifications, images). ZIP exports also include the actual attachment files.
Query Parameters
| Param | Values | Default |
|---|---|---|
format | json, csv, xlsx | json |
Returns all vehicles the user owns, with all nested records. Batch-processed (25 vehicles per batch) with a 1 GB memory limit to handle large collections.
Same as above, but the response is a ZIP file containing the JSON export plus all attachment files from the uploads/ directory.
Upload a previously exported file. The import is fully transactional - either everything succeeds, or nothing is committed. All sub-entities are created, including fuel records, service records, parts, consumables, MOT records, insurance, road tax, todos, and specifications. Duplicate VINs and registration numbers are handled gracefully.
Imports the JSON data and restores attachment files to the correct directories.
Permanently deletes all vehicles belonging to the authenticated user. This is a destructive, irreversible operation.
Query Parameters
| Param | Type | Description |
|---|---|---|
cascade | bool | Also delete all sub-records (default: true) |
System API
A handful of utility endpoints for health checking, diagnostics, and third-party integrations.
Returns "OK" with a 200 status. No authentication required. Use this for Docker health checks or load balancer probes.
Checks database connectivity and verifiable writable paths (uploads, var/cache, var/log). No authentication required.
Accepts log messages from the frontend for server-side aggregation. No authentication required. Useful for debugging client-side issues in production.
Handles eBay marketplace account deletion challenge and notification webhooks. Required for eBay API compliance.
Cost Calculator Service
The CostCalculator is the engine behind every cost display in the application. It queries
across all record types - fuel, parts, consumables, service, insurance, road tax, and MOT - to compute
total costs, per-category breakdowns, cost per mile, average fuel consumption (in both MPG and L/100km),
and comprehensive vehicle statistics. It depends on the Entity Manager for database queries and the
Depreciation Calculator for current-value computations.
The dashboard totals endpoint, individual vehicle stats, and cost breakdown pages all delegate to this service. Results are cached at the controller level (2-minute TTL in the dashboard pool) to avoid expensive re-computation on every page load.
Depreciation Calculator Service
The DepreciationCalculator implements four depreciation methods and generates year-by-year
schedules showing the vehicle's value over time:
- automotive_standard (default) - Mimics real-world vehicle depreciation: 20% in the first year, then 15% per year thereafter. This is the most realistic for most vehicles.
- straight_line - Equal depreciation each year: purchase cost divided by the number of years.
- declining_balance - A fixed percentage of the remaining value each year (the rate is configurable, default 20%).
- double_declining - An accelerated method: double the straight-line rate applied to the remaining value each year.
The schedule output includes the value at the start of each year, the depreciation amount, and the cumulative depreciation. This powers the depreciation LineChart on the vehicle details page.
DVLA API Service
The DvlaApiService integrates with the UK Driver and Vehicle Licensing Agency API for
vehicle registration lookups. It supports two authentication modes: OAuth2 client-credentials flow
(using client ID and secret) and direct API key authentication.
The service implements exponential backoff retry logic for 429 (rate limited) and
504 (gateway timeout) responses. If all retries are exhausted, it throws a DvlaBusyException.
Successful responses are cached to avoid repeated API calls for the same registration.
DVSA API Service
The DvsaApiService connects to the UK Driver and Vehicle Standards Agency API for MOT
history retrieval. Like the DVLA service, it supports both OAuth2 and API key authentication.
Key methods:
getMotHistory(registration)- Full MOT test historyparseFailureItems(data)- Extracts structured failure items from raw dataparseAdvisoryItems(data)- Extracts structured advisory itemsgetCurrentMotStatus(data)- Derives the current MOT statusgetPassRate(data)- Calculates the vehicle's overall MOT pass rate
Feature Flag Service
The FeatureFlagService manages the 49 feature flags that control which parts of the
application are available to each user. The service resolves effective flags by merging
the system-wide defaults with any per-user overrides. Admin users automatically get all features enabled,
regardless of overrides.
Key methods:
getEffectiveFlags(user)- Returns the resolved flag map for a userisFeatureEnabled(user, featureKey)- Check a single flagsetFeatureOverride(user, featureKey, enabled)- Set one overridebulkSetFeatureOverrides(user, overrides)- Set many overrides at onceresetFeatureOverrides(user)- Remove all overrides, revert to defaultsseedDefaults()- Create or update the 49 default flagsgetAllFlagsGrouped()- Returns flags organised by category for the admin UI
The FeatureFlagSubscriber event subscriber enforces these flags on every API request.
If a user tries to access a route that requires a disabled feature, they receive a 403
response. Routes for authentication, admin, and lookups are always allowed regardless of flags.
Receipt OCR Service
The ReceiptOcrService uses Tesseract OCR to extract structured data
from photographs of receipts. When you upload a receipt and trigger OCR, the service analyses the
image and returns field values that can pre-fill forms automatically.
Supported receipt types:
- Fuel receipts - Extracts date, total cost, litres, station name, and fuel type
- Part receipts - Extracts product name, part number, price, quantity, and supplier
- Consumable receipts - Same as part receipts
- Service receipts - Extracts work description, labour cost, parts cost, and date
For best results, photograph receipts in good lighting against a dark background. Avoid wrinkles, folds, and reflections. Thermal receipts (common at petrol stations) should be photographed promptly as they fade over time.
Report Engine
The ReportEngine is a template-driven report generation system. Each report is defined by
a JSON template that specifies data sources, calculations, layout, and styling. The engine reads these
templates, queries the database for the relevant data, performs calculations, and outputs the result
as XLSX (via PhpSpreadsheet) or PDF (via TCPDF).
The system supports unit conversion - distances can be rendered in miles or kilometres based on user preference. There are 13 built-in templates (listed in the Reports API section above), and the template format is extensible for custom reports.
URL Scraper Service
The UrlScraperService extracts product information from retail URLs. When you paste a
product URL into the parts or consumables form, this service attempts to pull the product name,
price, description, images, and part numbers automatically.
It uses an adapter pipeline - each adapter is tried in order until one succeeds:
- eBay Browse API - Fast path for eBay links using the official API (client credentials flow)
- Shopify Adapter - Parses Shopify product JSON and HTML
- Amazon Adapter - Uses the Amazon product API (API key required)
- eBay HTML Adapter - Fallback HTML parsing for eBay when the API isn't available
- Generic DOM Adapter - Last resort: parses OpenGraph meta tags, JSON-LD, and DOM elements
The service handles bot detection, retries on transient failures, and gracefully returns partial results when not all fields can be extracted.
Vehicle Export Service
The VehicleExportService serialises vehicles and all their sub-records into JSON format.
It processes vehicles in batches of 25 with a 1 GB memory limit to handle large collections. The ZIP
mode additionally packages all attachment files into the archive, preserving the directory structure.
Vehicle Import Service
The VehicleImportService is approximately 2,800 lines of import logic that handles every
entity type. Imports are fully transactional - if any part of the import fails, the entire operation
is rolled back. The service creates vehicles and all sub-entities (fuel records, service records with
items, parts, consumables, MOT records, insurance policies, road tax, todos, attachments, and specifications).
It handles duplicate detection, ID remapping, and relationship reconstruction.
Specification Scraper Service
The VehicleSpecificationScraperService fetches vehicle specifications from external
sources using an adapter pattern. Three adapters are registered:
- DvlaAdapter - Extracts specification data from DVLA vehicle records
- ApiNinjasMotorcycleAdapter - Queries the API Ninjas Motorcycles API for detailed specs
- ApiNinjasCarAdapter - Queries the API Ninjas Cars API
Adapters are tried in priority order. Results are merged to fill gaps - if one adapter returns engine specs but not chassis data, the next adapter can fill in the missing fields. Covers approximately 40 specification fields including engine, fluids, drivetrain, chassis, brakes, tyres, dimensions, weights, and performance data.
VIN Decoder Service
The VinDecoderService decodes Vehicle Identification Numbers via the API Ninjas VIN API.
It validates the 17-character format (rejecting VINs containing I, O, or Q, which are never used in
valid VINs), sends the query, and returns decoded information: make, model, year, country of manufacture,
and vehicle class. Decoded data is cached on the vehicle entity to avoid repeated API calls.
Attachment Linking Service
The AttachmentLinkingService manages bidirectional links between attachments and entities.
When you link a receipt to a fuel record, this service updates both the attachment's entity reference
and the fuel record's receipt reference. It also handles file reorganisation - when an
attachment is linked to a vehicle, the physical file is moved from the generic uploads directory to
uploads/vehicles/{registration}/{category}/ for clean file management.
Entity Reference
This section documents every Doctrine entity in the system - the database tables, their fields, types, and relationships. Click on an entity card to expand its field list.
User
| Field | Type | Notes |
|---|---|---|
id | int (PK, auto) | |
email | string(180), unique | Login identifier |
roles | json | Default: ["ROLE_USER"] |
password | string, nullable | Hashed (bcrypt/argon2) |
firstName | string(100) | |
lastName | string(100) | |
country | string(2) | Default: 'GB' |
passwordChangeRequired | bool | Default: false |
isActive | bool | Default: true |
isVerified | bool | Default: false |
createdAt | datetime | |
updatedAt | datetime, nullable | |
lastLoginAt | datetime, nullable |
Relationships: OneToMany → Vehicle (owner)
Vehicle
| Field | Type | Notes |
|---|---|---|
id | int (PK) | |
owner | ManyToOne → User | Required |
vehicleType | ManyToOne → VehicleType | Required |
name | string(100) | Display name |
make | string(50), nullable | |
model | string(50), nullable | |
year | int, nullable | |
vin | string(17), unique, nullable | |
vinDecodedData | json, nullable | Cached VIN decode result |
registrationNumber | string(20), nullable | |
engineNumber | string(50), nullable | |
v5DocumentNumber | string(50), nullable | UK V5C reference |
purchaseCost | decimal(10,2) | |
purchaseDate | date | |
purchaseMileage | int, nullable | |
vehicleColor | string(20), nullable | |
status | string(20) | Live, Sold, Scrapped, Exported |
depreciationMethod | string(20) | Default: automotive_standard |
depreciationYears | int | Default: 10 |
depreciationRate | decimal(5,2) | Default: 20.00 |
serviceIntervalMonths | int | Default: 12 |
serviceIntervalMiles | int | Default: 4000 |
roadTaxExempt | bool, nullable | Override; auto at age ≥ 30 |
motExempt | bool, nullable | Override; auto at age ≥ 30 |
securityFeatures | text, nullable | |
createdAt / updatedAt | datetime |
Computed fields: currentMileage (max fuel record mileage), lastServiceDate, motExpiryDate, roadTaxExpiryDate, insuranceExpiryDate, age, isClassic (≥25 yr), isRoadTaxExempt (≥30 yr), isMotExempt (≥30 yr).
Relationships: OneToMany → FuelRecord, ServiceRecord, Part, Consumable, MotRecord, RoadTax, Todo, VehicleImage, VehicleStatusHistory, InsurancePolicies (ManyToMany). OneToOne → Specification. All cascade remove.
Fuel Record
| Field | Type |
|---|---|
id | int (PK) |
vehicle | ManyToOne → Vehicle (CASCADE) |
date | date |
litres | decimal(8,2) |
cost | decimal(8,2) |
mileage | int |
fuelType | string(50), nullable |
station | string(200), nullable |
notes | text, nullable |
receiptAttachment | ManyToOne → Attachment (SET NULL) |
createdAt | datetime |
Computed methods: calculateMpg(), getPricePerLitre(), calculateCostPerMile(), convertMpgToLitres100km()
Service Record & Service Item
| Field | Type |
|---|---|
id | int (PK) |
vehicle | ManyToOne → Vehicle (CASCADE) |
serviceDate | date |
serviceType | string(50) |
laborCost | decimal(10,2) |
partsCost | decimal(10,2) |
consumablesCost | decimal(10,2), nullable |
additionalCosts | decimal(10,2) |
mileage | int, nullable |
serviceProvider | string(100), nullable |
workPerformed | text, nullable |
nextServiceDate / nextServiceMileage | date / int |
motRecord | ManyToOne → MotRecord |
includedInMotCost | bool (default true) |
includesMotTestCost | bool (default false) |
notes, createdAt, receiptAttachment | - |
Relationships: OneToMany → ServiceItem (cascade persist/remove, orphanRemoval)
| Field | Type |
|---|---|
id | int (PK) |
serviceRecord | ManyToOne → ServiceRecord (CASCADE) |
type | string(20) - part, labour, consumable |
description | string(255), nullable |
cost | decimal(10,2) |
quantity | decimal(10,2), default 1.00 |
consumable | ManyToOne → Consumable (SET NULL) |
part | ManyToOne → Part (SET NULL) |
Part
| Field | Type |
|---|---|
id | int (PK) |
vehicle | ManyToOne → Vehicle (CASCADE) |
name / description | string(200) |
partNumber | string(100), nullable |
sku | string(100), nullable |
manufacturer | string(100), nullable |
supplier | string(100), nullable |
cost | decimal(10,2) |
price | decimal(10,2), nullable |
quantity | int, default 1 |
purchaseDate | date |
partCategory | ManyToOne → PartCategory (SET NULL) |
warrantyMonths | int, nullable |
installationDate | date, nullable |
mileageAtInstallation | int, nullable |
productUrl | string(500), nullable |
imageUrl | string(500), nullable |
includedInServiceCost | bool, default false |
serviceRecord | ManyToOne → ServiceRecord (SET NULL) |
todo | ManyToOne → Todo (SET NULL) |
motRecord | ManyToOne → MotRecord (SET NULL) |
receiptAttachment | ManyToOne → Attachment (SET NULL) |
notes, createdAt | - |
Consumable
| Field | Type |
|---|---|
id | int (PK) |
vehicle | ManyToOne → Vehicle (CASCADE) |
consumableType | ManyToOne → ConsumableType |
description | string(200), nullable |
brand | string(100), nullable |
partNumber | string(100), nullable |
cost | decimal(10,2), nullable |
quantity | decimal(8,2), nullable |
supplier | string(100), nullable |
replacementInterval | int (miles), nullable |
nextReplacement | int (miles), nullable |
lastChanged | date, nullable |
mileageAtChange | int, nullable |
productUrl | string(500), nullable |
includedInServiceCost | bool, default false |
serviceRecord, todo, motRecord, receiptAttachment | ManyToOne (SET NULL) |
notes, createdAt, updatedAt | - |
MOT Record
| Field | Type |
|---|---|
id | int (PK) |
vehicle | ManyToOne → Vehicle (CASCADE) |
testDate | date |
result | string(20) - Pass, Fail, Advisory |
testCost | decimal(10,2) |
repairCost | decimal(10,2), default 0 |
mileage | int, nullable |
testCenter | string(100), nullable |
expiryDate | date, nullable |
motTestNumber | string(50), nullable |
testerName | string(100), nullable |
isRetest | bool, default false |
advisories, failures, repairDetails, notes | text, nullable |
receiptAttachment | ManyToOne → Attachment (SET NULL) |
createdAt | datetime |
Insurance Policy
| Field | Type |
|---|---|
id | int (PK) |
provider | string(100) |
policyNumber | string(100), nullable |
annualCost | decimal(10,2), nullable |
startDate / expiryDate | date, nullable |
coverageType | string(50), nullable |
excess | decimal(10,2), nullable |
ncdYears | int, nullable |
mileageLimit | int, nullable |
autoRenewal | bool, default false |
notes | text, nullable |
createdAt | datetime |
Relationships: ManyToMany → Vehicle (join table: insurance_policy_vehicles)
Road Tax
| Field | Type |
|---|---|
id | int (PK) |
vehicle | ManyToOne → Vehicle (CASCADE) |
startDate / expiryDate | date, nullable |
amount | decimal(10,2), nullable |
frequency | string(10), default 'annual' |
sorn | bool, default false |
notes | text, nullable |
createdAt | datetime |
Todo
| Field | Type |
|---|---|
id | int (PK) |
vehicle | ManyToOne → Vehicle (CASCADE) |
title | string(255), NotBlank |
description | text, nullable |
done | bool, default false |
dueDate | datetime, nullable |
completedBy | datetime, nullable |
createdAt / updatedAt | datetime |
Relationships: OneToMany → Part, OneToMany → Consumable
Attachment
| Field | Type |
|---|---|
id | int (PK) |
filename | string(255) |
originalName | string(255) |
vehicle | ManyToOne → Vehicle (CASCADE), nullable |
user | ManyToOne → User (CASCADE) |
mimeType | string(100) |
fileSize | int |
entityType | string(50), nullable |
entityId | int, nullable |
category | string(50), nullable |
description | text, nullable |
storagePath | string(255), nullable |
uploadedAt | datetime |
Specification
OneToOne with Vehicle (CASCADE). Approximately 40 string fields covering:
- Engine: type, displacement, power, torque, compression, bore, stroke, fuelSystem, cooling, sparkplugType
- Fluids: coolantType/Capacity, engineOilType/Capacity, transmissionOilType/Capacity, middleDriveOilType/Capacity
- Drivetrain: gearbox, transmission, finalDrive, clutch
- Chassis: frame, frontSuspension, rearSuspension, staticSagFront/Rear
- Brakes: frontBrakes, rearBrakes
- Tyres: frontTyre, rearTyre, frontTyrePressure, rearTyrePressure
- Dimensions: frontWheelTravel, rearWheelTravel, wheelbase, seatHeight, groundClearance
- Weights: dryWeight, wetWeight
- Performance: fuelCapacity, topSpeed
- Metadata: additionalInfo, scrapedAt, sourceUrl
Vehicle Image
| Field | Type |
|---|---|
id | int (PK) |
vehicle | ManyToOne → Vehicle (CASCADE) |
path | string(255) |
caption | string(255), nullable |
isPrimary | bool, default false |
displayOrder | int, default 0 |
isScraped | bool, default false |
sourceUrl | string(255), nullable |
uploadedAt | datetime |
Vehicle Assignment
Unique constraint on (vehicle_id, assigned_to_id).
| Field | Type |
|---|---|
id | int (PK) |
vehicle | ManyToOne → Vehicle (CASCADE) |
assignedTo | ManyToOne → User (CASCADE) |
assignedBy | ManyToOne → User (SET NULL), nullable |
canView | bool, default true |
canEdit | bool, default true |
canAddRecords | bool, default true |
canDelete | bool, default false |
createdAt / updatedAt | datetime |
Lookup Entities
These are simple reference entities that power dropdowns and categorisation throughout the application. They are seeded from bundled JSON fixtures.
| Entity | Table | Key Fields |
|---|---|---|
| VehicleType | vehicle_types | id, name (Motorcycle, Car, Van, Truck, EV) |
| VehicleMake | vehicle_makes | id, name, vehicleType, isActive |
| VehicleModel | vehicle_models | id, name, make, vehicleType, startYear, endYear, imageUrl, isActive |
| ConsumableType | consumable_types | id, name, unit, description, vehicleType |
| PartCategory | part_categories | id, name, description, vehicleType |
| SecurityFeature | security_features | id, name, description, vehicleType |
Feature Flag Entities
| Entity | Table | Key Fields |
|---|---|---|
| FeatureFlag | feature_flags | id, featureKey (unique), label, description, category, defaultEnabled, sortOrder, createdAt |
| UserFeatureOverride | user_feature_overrides | id, user, featureFlag, enabled, setBy, createdAt, updatedAt. Unique on (user_id, feature_flag_id) |
| UserPreference | user_preferences | id, user, name, value, createdAt, updatedAt |
| RefreshToken | refresh_tokens | id, refreshToken (unique), user, expiresAt, createdAt |
| Report | reports | id, user, name, templateKey, payload (json), vehicleId, generatedAt |
| VehicleStatusHistory | vehicle_status_histories | id, vehicle, user, oldStatus, newStatus, changeDate, notes, createdAt |
Frontend Overview
The web frontend is a React 18 single-page application built with Create React App.
It uses MUI 5 for the component library (including @mui/x-charts for
data visualisation), React Router v6 for client-side routing, Axios
for HTTP, and react-i18next for internationalisation with 21 languages
(including a Pirate Easter egg 🏴☠️).
The application wraps all pages in a layered context hierarchy: ThemeContext → AuthContext → UserPreferencesContext → VehicleContext → PermissionsContext. This means every page has access to the current theme, authentication state, user preferences, the global vehicle list, and feature-flag permissions - without prop drilling.
Key Pages
| Page | Route | Description |
|---|---|---|
| Dashboard | / | Stat cards, pie chart (cost per vehicle), bar charts (monthly fuel & maintenance), vehicle cards with status tabs |
| Vehicles | /vehicles | Vehicle list with card/table toggle, status filter, sort, pagination, add/edit dialog |
| Vehicle Details | /vehicles/:id | 7 tabs: Overview, Statistics, Depreciation, Specifications, Pictures, Documentation, Manuals |
| Fuel Records | /fuel | Fuel record list with vehicle selector |
| Service Records | /service | Service records with item details and cost totals |
| Parts | /parts | Parts list with linked MOT/service popups |
| Consumables | /consumables | Consumables with replacement tracking |
| MOT Records | /mot | MOT records with DVSA import |
| Insurance | /insurance | Insurance policies with expiry highlighting |
| Road Tax | /road-tax | Road tax records with SORN tracking |
| Todos | /todo | Task list with linked parts/consumables |
| Reports | /reports | 13 templates, XLSX preview, PDF/XLSX download |
| Import/Export | /tools/import-export | JSON/ZIP import with preview, filtered export |
| Profile | /profile | Edit profile, change password |
| Admin Users | /admin/users | User management (admin only) |
| Admin User Details | /admin/users/:id | Feature flags and vehicle assignments per user |
Dashboard
The dashboard is the landing page after login. It shows stat cards at the top summarising total spend across categories (fuel, parts, consumables, services) for a configurable period. Below that is a pie chart showing total cost per vehicle (vehicles beyond the top 9 are grouped into an "Other" slice), and two stacked bar charts showing monthly fuel and maintenance spend over 3/6/12/24/36 months. At the bottom, vehicle cards are displayed in status tabs (Live, Sold, Scrapped, Exported) with key metrics and a drag-to-reorder capability.
Vehicles & Vehicle Details
The Vehicles page supports both card and table view modes with a toggle. Vehicles can be filtered by status, sorted by various fields (with sort preference persisted), and paginated. The add/edit dialog supports registration lookup (which scrapes data from the DVLA), make/model/year selection, depreciation settings, and MOT/road-tax exempt toggles.
The Vehicle Details page has seven tabs. Overview shows basic info, VIN decoder, and license plate rendering. Statistics displays cost breakdowns in a pie chart. Depreciation shows a line chart with the year-by-year schedule. Specifications shows ~40 fields with a "Scrape from web" button. Pictures is an image gallery with upload, reorder, and primary selection. Documentation and Manuals handle PDF/document uploads per category.
Records & Forms
All record pages (Fuel, Service, Parts, Consumables, MOT, Insurance, Road Tax, Todos) follow a consistent pattern: a list view with a vehicle selector filter, action buttons for add/edit/delete, and a dialog-based form. Each form handles receipt photograph upload, URL scraping (for parts and consumables), and entity linking.
Service record forms are the most complex - they support inline addition of service items, each typed as a part, labour charge, or consumable, with the ability to link to existing Part or Consumable entities. MOT record forms include structured advisory and failure item entry. Insurance forms support multi-vehicle policy assignment.
Admin Panel
The admin panel is available to users with ROLE_ADMIN. The Admin Users page
lists all registered users with their vehicle counts, last login times, and active status. Admins can create
new users, toggle active status, force password changes, and manage roles.
The Admin User Details page has two sections: feature flags (categorised toggle switches for all 49 flags with bulk update and reset-to-defaults) and vehicle assignments (assign vehicles to the user with granular canView/canEdit/canAddRecords/canDelete permissions).
Reports
The Reports page lets you generate reports from 13 built-in templates. Select a template, optionally filter by vehicle and date range, and generate. The system shows an in-browser XLSX preview powered by SheetJS, so you can inspect the data before downloading. Downloads are available in XLSX, PDF, and CSV formats.
Contexts & Hooks
The frontend uses five React contexts:
- AuthContext - Manages JWT tokens (SafeStorage), login/logout, user profile, 401 interceptor
- ThemeContext - MUI light/dark theme, persisted to localStorage and user preferences
- UserPreferencesContext - Default vehicle, rows per page, distance unit, currency
- VehicleContext - Global vehicle list with 30-second cache, auto-fetch on mount
- PermissionsContext - Feature-flag and vehicle-assignment based access control; admin bypass
And seven custom hooks:
useApi- Generic data fetching with loading/error stateuseDistance- Distance unit conversion (miles/km) based on user preferenceuseFileDrop- Drag-and-drop file handlinguseNotifications- SSE stream with fallback polling, 90-day dismissed/snoozed cleanupuseSortPreference- Persisted sort field/orderusePagination- Pagination state with user-preferred default rowsuseVehicleSelection- Vehicle selection synced with user preferences
Internationalization
The web app supports 21 languages via react-i18next, loading translations from
/public/locales/{lang}/translation.json. The languages are: Arabic, Czech, Danish, German,
English, Spanish, Finnish, French, Hindi, Italian, Japanese, Korean, Dutch, Norwegian, Pirate 🏴☠️, Polish,
Portuguese, Russian, Swedish, Turkish, and Chinese.
The language selector is in the user preferences dialog and persists the choice to both localStorage and the server. Currency is auto-mapped from the language/locale choice.
Mobile App Overview
The mobile app is built with React Native 0.73 and TypeScript. It uses React Native Paper (Material Design 3) for the UI, React Navigation for navigation, and Axios for HTTP communication. The app supports 20 languages with bundled JSON translations (no Pirate - that's a web-only Easter egg).
The bottom tab navigation has five tabs: Dashboard, Vehicles, Fuel, Service, and More. The "More" tab provides access to MOT Records, Parts, Consumables, Vehicle Lookup, and Settings. All screens are feature-gated through the PermissionsContext.
Web & Standalone Modes
The defining feature of the mobile app is its dual-mode architecture. On first launch, the ServerConfigScreen presents two choices:
- Connect to Server - Enter your Vehicle Manager server URL. The app tests the connection before proceeding. All data is synced with the backend. The URL is auto-normalised (adds https:// if missing, appends /api if needed).
- Use Standalone - The app runs entirely offline. A
LocalApiAdapterprovides full CRUD operations backed by AsyncStorage. No server required. Data lives entirely on the device.
In standalone mode, the PermissionsContext grants all permissions, and authentication is handled locally with a simple email/password stored in AsyncStorage.
Dashboard
The mobile dashboard shows a sync status banner (online/offline with pending change count), stat cards (vehicles, fuel, service, mileage), and vehicle notifications with severity colours (danger for expired, warning for ≤30 days, info for ≤60 days). Notifications can be dismissed, cleared, or reset. The dashboard fires Android system notifications for critical items via the @notifee service.
Vehicles
The vehicle list supports search, status filter chips (All/Live/Sold/Scrapped), and a FAB button to add new vehicles. Each vehicle card shows the registration, make/model, mileage (with unit conversion), and an icon based on fuel type. Tapping a vehicle opens the detail screen with a header card, quick actions (Add Fuel / Add Service), cost summary grid, important dates with days-remaining indicators, key info list, and navigation to all record types.
Records & Forms
All record screens follow a consistent pattern: vehicle selector, card-based list with chips for metadata,
and form screens for creating/editing. Each form supports receipt photo capture via camera
or gallery (using react-native-image-picker). The QuickFuelScreen provides a
streamlined one-tap fuel logging experience with auto-populated mileage.
The vehicle form has sections for Basic Info, Status (segmented buttons), Specifications, and Purchase Info. MOT forms include structured advisory and failure entry. The DVSA Vehicle Lookup screen lets you enter a registration and view specs plus full MOT history without creating any records.
Offline & Sync
The offline system is built around two core pieces: SyncContext and useOfflineData.
SyncContext monitors network connectivity via @react-native-community/netinfo.
When the device is offline, mutations (create, update, delete) are queued in AsyncStorage. When connectivity
returns, the queue is replayed with a 2-second debounce to avoid hammering the server. Each operation is
retried up to 5 times. Operations that fail with a 4xx status code are automatically discarded (since
the data is permanently invalid). Entity-to-endpoint mapping covers 10 entity types.
useOfflineData provides a cache-first data-fetching hook. On mount, it immediately returns cached data (if available and less than 24 hours old), then fetches fresh data from the network. If the network request fails, the cached data remains visible. This ensures the app always shows something, even in spotty connectivity.
Notifications
The NotificationService uses @notifee/react-native to create Android
notification channels (vehicle-reminders and vehicle-urgent). It calculates notifications for live
vehicles based on MOT expiry, insurance expiry, road tax expiry, and service due dates. Severity levels
are: danger (expired or < 0 days), warning (≤ 30 days), and
info (≤ 60 days). The top 5 most critical notifications are fired as system notifications.
Settings
The Settings screen provides comprehensive configuration:
- Sync status - Shows online/offline state with a "Sync Now" button and pending count
- Language selector - 20 languages with flag emojis and native names
- Theme - Light, System, or Dark (segmented buttons)
- Currency - 17 currencies (GBP, USD, EUR, JPY, etc.)
- Distance unit - Miles or kilometres
- Volume unit - Litres or gallons
- Notification toggles - Service, MOT, and insurance reminder switches
- Server info - Connected server URL
- Clear Data & Disconnect - Reset the app to ServerConfigScreen
- Logout
Administration
The admin panel is available to users with the ROLE_ADMIN role. It provides three main
capabilities: user management, feature flag control, and vehicle assignment management. All admin
operations are enforced server-side - the frontend simply hides the UI for non-admin users, but the
API will reject unauthorised requests regardless.
User Management
From the Admin Users page, you can see every registered account along with their vehicle count, role, last login time, and active status. Available actions include:
- Create user - Set up a new account with email, password, name, and role. Useful for onboarding family members or fleet drivers.
- Toggle active - Disable or re-enable a user account. Disabled users cannot log in.
- Force password change - Sets a flag that forces the user to choose a new password on their next login. Use this if you suspect a compromised account.
- Manage roles - Promote a user to ROLE_ADMIN or demote back to ROLE_USER.
Feature Flags
Vehicle Manager has 49 feature flags organised by category. By default, all flags are enabled for everyone. Admins can override flags on a per-user basis - for example, disabling the Reports feature for a specific user, or restricting a user to only view fuel records and nothing else.
The Admin User Details page shows all 49 flags as categorised toggle switches. Changes are saved with a bulk update. You can also reset a user back to the system defaults with one click. Admin users always get all features enabled regardless of overrides.
The FeatureFlagSubscriber enforces these flags on every API request. If a disabled feature
is accessed, the server returns 403 Forbidden. Authentication, admin, and lookup routes are
exempted from flag checking.
Vehicle Assignments
Vehicle assignments let you share vehicles between users. This is useful in scenarios like:
- A family sharing access to household vehicles
- A fleet manager giving drivers read-only access to their assigned vehicles
- A mechanic needing to add service records to customer vehicles
Each assignment has four granular permissions:
| Permission | Default | Allows |
|---|---|---|
canView | true | See the vehicle and its records |
canEdit | true | Modify vehicle details |
canAddRecords | true | Create fuel, service, and other records |
canDelete | false | Delete the vehicle and its records |
A user can be assigned to the same vehicle only once (enforced by a unique constraint). Assignments are managed through the Admin User Details page or directly via the API.
FAQ & Troubleshooting
General Questions
Run this SQL against your database: UPDATE users SET roles = '["ROLE_USER","ROLE_ADMIN"]' WHERE id = 1; - replacing 1 with your user ID. There is no UI for the initial admin promotion since it would be a security risk.
Edit config/packages/lexik_jwt_authentication.yaml and change the token_ttl value (in seconds). The default is 3600 (one hour). Restart the PHP container after changes.
Five types: Motorcycle, Car, Van, Truck, and EV. Each type has its own set of consumable types, part categories, and security features.
Four methods are available. The default is automotive_standard, which applies 20% depreciation in the first year and 15% per year thereafter - closely matching real-world vehicle depreciation patterns. You can also choose straight-line, declining balance, or double-declining balance.
Parts are permanent replacements - brake pads, air filters, bulbs, exhaust components. They're bought, installed, and that's it. Consumables are items that need regular, repeated replacement - engine oil, coolant, brake fluid, tyres, chain lube. Consumables have replacement-interval tracking, so the system can notify you when they're due.
17 currencies: GBP (£), USD ($), EUR (€), JPY (¥), AUD, CAD, CHF, CNY, DKK, HKD, INR, KRW, NOK, NZD, PLN, SEK, and TRY. The currency selector is in Settings (mobile) or Preferences (web).
The web app supports 21 languages: Arabic, Czech, Danish, German, English, Spanish, Finnish, French, Hindi, Italian, Japanese, Korean, Dutch, Norwegian, Pirate 🏴☠️, Polish, Portuguese, Russian, Swedish, Turkish, and Chinese. The mobile app supports 20 of these (no Pirate).
Docker Issues
Check that Docker has at least 4 GB of memory allocated (Docker Desktop → Settings → Resources). Ensure ports 3000, 8081, 3306, and 6379 are not already in use by other services. Run docker-compose logs for specific error messages.
MySQL has a health check configured - it may take 30–60 seconds to become ready after starting. Check with docker-compose ps and wait for the MySQL container to show "healthy". Verify DATABASE_URL in your .env file points to the correct host (usually mysql inside Docker).
Ensure the Redis container is running and healthy: docker-compose ps redis. Check that REDIS_URL uses the hostname redis (not localhost) inside Docker. The default URL should be redis://redis:6379.
Use docker-compose logs -f php for backend logs, docker-compose logs -f nginx for request logs, or docker-compose logs -f frontend for React dev server output. Add --tail=100 to limit output.
docker-compose exec php bin/console doctrine:migrations:migrate --no-interaction - though migrations typically run automatically via the PHP entrypoint script on container start.
Two commands: docker-compose exec php bin/console doctrine:fixtures:load --no-interaction for lookup data (vehicle types, makes, models, categories), then docker-compose exec php bin/console app:seed-feature-flags for the 49 feature flags.
API Issues
Your JWT token has expired (tokens last 1 hour by default). Use the refresh endpoint: POST /api/auth/refresh with your refresh token in the body. The web app handles this automatically via the AuthContext interceptor.
Either a feature flag is disabled for your user, or you're trying to access an admin-only endpoint without ROLE_ADMIN. Check your effective features via GET /api/me and ask an admin to enable the relevant flag.
In development, CORS is set to allow all origins. In production, check the CORS_ALLOW_ORIGIN environment variable - it must match your frontend's URL exactly (including the protocol and port).
Check the file size against UPLOAD_MAX_BYTES (default 200 MB). Also verify the MIME type is allowed - the system rejects certain file types for security. The PHP container has a 200 MB upload limit and 1 GB memory limit.
Ensure Tesseract is installed in the PHP container (it's included in the default Dockerfile). OCR quality depends heavily on image quality - photograph receipts in good lighting, flat against a dark surface. Thermal receipts from petrol stations fade quickly, so photograph them promptly.
Mobile App Issues
On Android emulators, use 10.0.2.2 instead of localhost - the emulator routes that address to the host machine. Make sure the URL ends with /api. For physical devices, use your computer's local network IP (e.g., 192.168.1.x:8081/api).
Check your network connectivity first. The SyncContext waits 2 seconds after reconnection before syncing, and retries each operation up to 5 times. Open Settings and tap "Sync Now" to force immediate sync. If operations fail with 4xx errors, they're discarded as permanently invalid.
System notifications require explicit permission on Android. Check your device's notification settings for the app. Only Android is supported - iOS notifications are not yet implemented. The app creates two channels: "vehicle-reminders" and "vehicle-urgent".
Go to Settings → scroll to the bottom → tap "Clear Data & Disconnect". This wipes local data and restarts the app at the ServerConfigScreen, where you can enter a server URL. Note: standalone data is not migrated - back it up first if needed.
Import & Export Issues
Ensure the JSON format matches the export format exactly. Common issues include duplicate VINs or registration numbers (the system enforces uniqueness), missing required fields, and malformed dates. The import is transactional - if anything fails, nothing is committed. Check the error response body for specifics.
Attachment files within the ZIP must be in an "attachments" subfolder matching the export structure. If files are in the wrong location, the vehicle data will import successfully but the attachments will be skipped.
Large vehicle collections with many attachments take time to process. The system processes in batches of 25 vehicles with a 1 GB memory limit. ZIP exports are especially slow because all attachment files must be read and compressed. For very large collections, consider exporting without attachments first (JSON format).
Vehicle Manager Documentation · GitHub · Open source & free forever