Browse the documentation
Building modules
Core data model
The core entities a module builds on — the main models, how they relate, and the fields a module can rely on.
A module extends the core by building on its data. This page describes the central database models, their fields and relationships, and shows how to access them from your own module. Knowing the data model means building modules that work with the core rather than creating parallel structures alongside it.
The domain in terms
The application manages winter service operations. The following terms run through the entire data model — knowing them saves a lot of searching later:
- Customers are the clients who commission the winter service.
- Customer Objects are the concrete locations (addresses) that are serviced.
- Drivers are the people who carry out the service (represented as
Userwith the driver role). - Vehicles are the machines and vehicles they use.
- Work Shifts record when a driver is on duty.
- Jobs (
service_jobs) are the individual service operations at a customer object. - GPS Points track the driver’s location during a job.
- Weather Snapshots record conditions at the start and end of a job.
The Job is the central entity: driver, vehicle, customer, location, GPS track, weather, and photos all hang from it. Almost every analysis in the system runs through it.
Relationships overview
Customer ─┬─ has many ──→ CustomerObject ──→ has many ──→ Job
└─ has many ──→ Job │
├─ belongs to ─→ User (driver)
User (driver) ──→ has many ──→ WorkShift ──→ has many ──→ Job│
├─ belongs to ─→ Vehicle
Vehicle ──────→ has many ──→ Job │
├─ has many ──→ GpsPoint
├─ has many ──→ WeatherSnapshot
├─ has many ──→ JobPhoto
└─ has many ──→ JobAudit
A Customer can have multiple CustomerObject locations; each location accumulates many Job records. Every Job points to exactly one driver, one vehicle, one customer, and one location — and collects its GPS points, weather snapshots, photos, and audit entries.
Models in detail
User
Table: users
A User is either an administrator or a driver — the role is stored in the role field. Drivers additionally receive OwnTracks credentials for GPS tracking.
| Field | Type | Description |
|---|---|---|
id | int | Primary key |
name | string | Full name |
email | string | Unique email |
password | hashed | Login password |
role | UserRole enum | admin or driver |
phone | string | Phone number |
locale | string | UI language (nullable; null = app default). Any locale registered in LocaleRegistry. Since 1.1.3 |
notes | text | Admin notes |
default_vehicle_id | FK | Preferred vehicle |
owntracks_username | string | OwnTracks GPS username |
owntracks_password_hash | string | OwnTracks password (bcrypt) |
dsgvo_informed_at | datetime | GDPR acknowledgment |
anonymized_at | datetime | When driver was anonymised |
Key methods:
$user->isAdmin(): bool
$user->isDriver(): bool
$user->isAnonymized(): bool
$user->displayName(): string // returns anonymized label if anonymized
$user->hasRole('admin'): bool
$user->hasPermission('jobs.view'): bool
Scopes: drivers(), admins(), withAnonymized(), onlyAnonymized()
Global scope: ExcludeAnonymizedScope hides anonymised users by default. This matters when working with driver data in a module: an anonymised driver does not appear in normal queries. When you need them — for example when processing historical data — use withAnonymized().
Relationships: roles, workShifts, serviceJobs, gpsPoints, defaultVehicle, dsgvoConfirmations
Customer
Table: customers
The Customer is the client. Several fields control the optional customer portal — for instance whether the customer is allowed to see GPS tracks, photos, or the driver’s name.
| Field | Type | Description |
|---|---|---|
id | int | Primary key |
name | string | Company/customer name |
contact_name | string | Contact person |
email | string | Primary email |
phone | string | Phone |
auto_notify_email | bool | Auto-send job notifications |
notification_email | string | Override email for notifications |
locale | string | Preferred UI language — any locale registered in LocaleRegistry |
password | hashed | Portal login password |
portal_enabled | bool | Portal access |
portal_show_gps | bool | Show GPS in portal |
portal_show_photos | bool | Show photos in portal |
portal_show_driver_name | bool | Show driver name in portal |
Relationships: objects (CustomerObject), serviceJobs, notificationLogs
CustomerObject
Table: customer_objects
A CustomerObject is a concrete site belonging to a customer — with address, coordinates, and service-relevant settings such as the salting threshold and notes for drivers. The lat/lon coordinates are the basis for weather queries and GPS attribution.
| Field | Type | Description |
|---|---|---|
id | int | Primary key |
customer_id | FK | Parent customer |
name | string | Object name (e.g. “Haupteingang”) |
street, zip, city | string | Address |
lat, lon | decimal(7) | Coordinates for weather/GPS |
contact_name, contact_email, contact_phone | string | On-site contact |
price_amount_cents | int | Service price in cents |
price_unit | string | Price unit |
plow_threshold_cm | int | Snow depth threshold for plowing |
salt_enabled | bool | Whether salting is applicable |
site_notes, access_notes | text | Notes for drivers |
auto_notify_email | bool | Auto-notify for this object |
notification_email | string | Object-specific notification email |
Monetary values are stored deliberately as integers in cents (price_amount_cents) — this avoids the rounding errors that floating-point amounts would introduce.
Relationships: customer, serviceJobs
Job (service_jobs)
Table: service_jobs
The Job is the individual service operation. Note: the model is named Job, but the table is service_jobs — that is why the relationship on other models is typically called serviceJobs.
| Field | Type | Description |
|---|---|---|
id | int | Primary key |
work_shift_id | FK | Parent work shift |
customer_id | FK | Customer |
customer_object_id | FK | Service location |
user_id | FK | Driver |
vehicle_id | FK | Vehicle used |
type | JobType enum | raumen, streuen, kontrolle, raumen_streuen |
started_at | datetime | Job start time |
ended_at | datetime | Job end time |
notes | text | Driver notes |
is_manual | bool | Manually entered (not GPS-tracked) |
Key methods:
$job->isCompleted(): bool // has ended_at
$job->isInGracePeriod(): bool // within 24h of completion
$job->isLocked(): bool // past grace period
$job->isGpsLocked(): bool // GPS locked after completion
$job->graceDeadline(): ?Carbon
$job->durationFormatted(): string // "2h 15min"
$job->localStartedAt(): Carbon
$job->localEndedAt(): Carbon
These methods reflect the job record locking concept: after completion, a job remains editable for a limited time (isInGracePeriod()), after which it is locked (isLocked()). This keeps the documented record unchanged — important if it is later used as evidence. In a module, rely on these methods rather than writing your own date comparisons.
Relationships: workShift, customer, customerObject, user, vehicle, gpsPoints, weatherSnapshots, jobPhotos, audits, notificationLogs, alertDismissals
WorkShift
Table: work_shifts
A WorkShift groups a driver’s jobs into a single shift. When ended_at is null, the shift is still running.
| Field | Type | Description |
|---|---|---|
id | int | Primary key |
user_id | FK | Driver |
started_at | datetime | Shift start |
ended_at | datetime | Shift end (null = ongoing) |
notes | text | Shift notes |
Relationships: user, jobs
Vehicle
Table: vehicles
| Field | Type | Description |
|---|---|---|
id | int | Primary key |
name | string | Vehicle name |
license_plate | string | License plate number |
owntracks_device_id | string | OwnTracks device identifier |
notes | text | Vehicle notes |
Key method: displayLabel(): string — returns “name (license_plate)”.
Relationships: serviceJobs
WeatherSnapshot
Table: weather_snapshots
Each job typically produces two WeatherSnapshot entries: one at the start (start) and one at the end (end). The raw_response field preserves the full provider response — so the conditions remain traceable even if the analysis logic changes later.
| Field | Type | Description |
|---|---|---|
id | int | Primary key |
job_id | FK | Parent job |
moment | WeatherMoment enum | start or end |
provider | string | Provider slug |
temperature | decimal(2) | °C |
precipitation | decimal(2) | mm |
snow_depth | decimal(2) | cm |
wind_speed | decimal(2) | km/h |
humidity | int | % |
weather_code | int | WMO weather code |
fetched_at | datetime | When fetched |
raw_response | JSON | Full API response |
Key methods: providerLabel(), weatherLabel() — return localised display strings.
Enums
Several fields are modelled as type-safe enums. This prevents invalid values in the database and gives you readable constants instead of raw strings.
enum JobType: string {
case Raumen = 'raumen'; // Snow removal/plowing
case Streuen = 'streuen'; // Salt spreading
case Kontrolle = 'kontrolle'; // Inspection
case RaumenStreuen = 'raumen_streuen'; // Combined
}
enum WeatherMoment: string {
case Start = 'start';
case End = 'end';
}
enum UserRole: string {
case Admin = 'admin';
case Driver = 'driver';
}
All enums have a label(): string method returning a localised display name. In a module, use this label() method rather than your own translations so that labels are consistent with the rest of the UI.
Supporting models
Beyond the central models there are a number of supporting models. Several are deliberately insert-only — they form the audit-safe part of the system, such as audit trails and consent records.
| Model | Table | Purpose |
|---|---|---|
GpsPoint | gps_points | GPS coordinates during jobs |
JobPhoto | job_photos | Photos taken during jobs |
JobAudit | job_audits | Audit trail (insert-only) |
Setting | settings | Key-value configuration |
Role | roles | Authorisation roles |
Permission | permissions | Authorisation permissions |
Module | modules | Installed modules |
ModuleApiToken | module_api_tokens | API authentication tokens |
ModLog | mod_logs | Module log entries (insert-only) |
NotificationLog | notification_logs | Notification audit trail |
AlertDismissal | alert_dismissals | Dismissed job alerts |
MonthlyStatistic | monthly_statistics | Pre-computed monthly stats |
DsgvoConfirmation | driver_dsgvo_confirmations | GDPR consent records (insert-only) |
OwntracksCredentialEvent | owntracks_credential_events | GPS credential audit (insert-only) |
Configuration is handled through Setting (key-value) — how to register and read module settings with a slug prefix is covered under ServiceProvider. Roles and permissions are covered under Permissions & roles.
Accessing models from your module
Access core models directly by importing from the App\Models namespace. There is no separate module interface for read access — you work with the same Eloquent models as the core:
use App\Models\Customer;
use App\Models\Job;
use App\Models\User;
use App\Models\Setting;
$customers = Customer::with('objects')->get();
$recentJobs = Job::where('ended_at', '>=', now()->subDays(7))->get();
$drivers = User::drivers()->get();
Two things to keep in mind: eager-load relationships with with(...) when you need them in a loop — otherwise you get many individual queries. And be aware of global scopes: User::drivers() will not return anonymised drivers, thanks to ExcludeAnonymizedScope. If you need the full dataset — for instance when migrating historical data — use withAnonymized(). Anyone setting up a module’s database schema will find the foundations under Database & migrations.