Skip to main content
Wintertrace
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 User with 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.

FieldTypeDescription
idintPrimary key
namestringFull name
emailstringUnique email
passwordhashedLogin password
roleUserRole enumadmin or driver
phonestringPhone number
localestringUI language (nullable; null = app default). Any locale registered in LocaleRegistry. Since 1.1.3
notestextAdmin notes
default_vehicle_idFKPreferred vehicle
owntracks_usernamestringOwnTracks GPS username
owntracks_password_hashstringOwnTracks password (bcrypt)
dsgvo_informed_atdatetimeGDPR acknowledgment
anonymized_atdatetimeWhen 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.

FieldTypeDescription
idintPrimary key
namestringCompany/customer name
contact_namestringContact person
emailstringPrimary email
phonestringPhone
auto_notify_emailboolAuto-send job notifications
notification_emailstringOverride email for notifications
localestringPreferred UI language — any locale registered in LocaleRegistry
passwordhashedPortal login password
portal_enabledboolPortal access
portal_show_gpsboolShow GPS in portal
portal_show_photosboolShow photos in portal
portal_show_driver_nameboolShow 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.

FieldTypeDescription
idintPrimary key
customer_idFKParent customer
namestringObject name (e.g. “Haupteingang”)
street, zip, citystringAddress
lat, londecimal(7)Coordinates for weather/GPS
contact_name, contact_email, contact_phonestringOn-site contact
price_amount_centsintService price in cents
price_unitstringPrice unit
plow_threshold_cmintSnow depth threshold for plowing
salt_enabledboolWhether salting is applicable
site_notes, access_notestextNotes for drivers
auto_notify_emailboolAuto-notify for this object
notification_emailstringObject-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.

FieldTypeDescription
idintPrimary key
work_shift_idFKParent work shift
customer_idFKCustomer
customer_object_idFKService location
user_idFKDriver
vehicle_idFKVehicle used
typeJobType enumraumen, streuen, kontrolle, raumen_streuen
started_atdatetimeJob start time
ended_atdatetimeJob end time
notestextDriver notes
is_manualboolManually 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.

FieldTypeDescription
idintPrimary key
user_idFKDriver
started_atdatetimeShift start
ended_atdatetimeShift end (null = ongoing)
notestextShift notes

Relationships: user, jobs


Vehicle

Table: vehicles

FieldTypeDescription
idintPrimary key
namestringVehicle name
license_platestringLicense plate number
owntracks_device_idstringOwnTracks device identifier
notestextVehicle 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.

FieldTypeDescription
idintPrimary key
job_idFKParent job
momentWeatherMoment enumstart or end
providerstringProvider slug
temperaturedecimal(2)°C
precipitationdecimal(2)mm
snow_depthdecimal(2)cm
wind_speeddecimal(2)km/h
humidityint%
weather_codeintWMO weather code
fetched_atdatetimeWhen fetched
raw_responseJSONFull 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.

ModelTablePurpose
GpsPointgps_pointsGPS coordinates during jobs
JobPhotojob_photosPhotos taken during jobs
JobAuditjob_auditsAudit trail (insert-only)
SettingsettingsKey-value configuration
RolerolesAuthorisation roles
PermissionpermissionsAuthorisation permissions
ModulemodulesInstalled modules
ModuleApiTokenmodule_api_tokensAPI authentication tokens
ModLogmod_logsModule log entries (insert-only)
NotificationLognotification_logsNotification audit trail
AlertDismissalalert_dismissalsDismissed job alerts
MonthlyStatisticmonthly_statisticsPre-computed monthly stats
DsgvoConfirmationdriver_dsgvo_confirmationsGDPR consent records (insert-only)
OwntracksCredentialEventowntracks_credential_eventsGPS 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.