Data model — User
The authenticated account holder. Implements JWTSubject (tymon/jwt-auth) so that role and subscription state are embedded directly in the token — the backend does not need a database lookup to authorize most requests. Uses SoftDeletes; deleted accounts are retained with deleted_at set.
Table: users
Core identity
| Column | Type | Nullable | Description |
|---|---|---|---|
| id | bigint | no | Primary key |
| name | string | no | Display name |
| string | no | Login credential (unique) | |
| password | string | no | bcrypt hash (hidden from serialization) |
| avatar_url | string | yes | CDN URL for profile avatar |
| avatar_key | string | yes | R2 storage key for the avatar file |
| email_verified_at | timestamp | yes | Null until email verification link is clicked |
| remember_token | string | yes | Laravel “remember me” token (hidden) |
Role and subscription
| Column | Type | Default | Description |
|---|---|---|---|
| role | string | user | admin or user |
| subscription_status | string | unpaid | unpaid or paid |
| subscription_tier | string | free | none, free, bronze, premium, or custom |
| trial_ends_at | timestamp | null | Free trial expiry (null if no trial) |
Defaults are applied in the booted() hook, not the migration, so they are guaranteed regardless of how the model is created.
Paddle billing columns
| Column | Type | Description |
|---|---|---|
| paddle_customer_id | string | Paddle customer identifier |
| paddle_subscription_id | string | Active subscription ID |
| paddle_price_id | string | Subscribed price ID |
| paddle_subscription_status | string | Paddle-native subscription status string |
| paddle_last_payment_status | string | Status of the most recent transaction |
| paddle_last_payment_failed_at | timestamp | Timestamp of last failed payment |
| paddle_scheduled_cancellation_at | timestamp | Scheduled end-of-period cancellation |
| paddle_canceled_at | timestamp | Actual cancellation timestamp |
| paddle_cancellation_source | string | internal (user-initiated) or external (Paddle-initiated) |
| paddle_last_event_at | timestamp | Timestamp of last processed Paddle webhook |
These columns are populated and updated by the Paddle webhook handler. The authoritative subscription truth is the Paddle dashboard; these columns are a local cache.
Two-factor authentication
| Column | Type | Description |
|---|---|---|
| two_factor_secret | string | TOTP secret (application-level encryption via Laravel Crypt::encrypt, hidden from serialization) |
| two_factor_confirmed_at | timestamp | When TOTP was confirmed; null means TOTP not active |
| two_factor_email_enabled | boolean | Whether email-based 2FA is enabled |
| two_factor_recovery_codes | array | Encrypted recovery codes (hidden from serialization) |
Account lifecycle
| Column | Type | Description |
|---|---|---|
| account_deletion_requested_at | timestamp | Timestamp of deletion request (soft-delete queue) |
| deleted_at | timestamp | SoftDeletes field; null for active accounts |
| created_at | timestamp | — |
| updated_at | timestamp | — |
Subscription tier ranks
Tiers are ordered by rank for canAccessSubscriptionTiers() gate checks:
| Tier | Rank |
|---|---|
none | 0 |
free | 0 |
bronze | 1 |
premium | 2 |
custom | 3 |
admin users and custom-tier users always pass all tier gates regardless of rank. An account with subscription_status = unpaid fails tier checks above free unless it is admin or custom.
JWT custom claims
Every issued token embeds three custom claims beyond the standard sub / iat / exp:
{
"sub": 42,
"role": "user",
"subscription_status": "paid",
"subscription_tier": "bronze"
}Token TTL is 20,160 minutes (14 days). There is no refresh endpoint — the frontend re-authenticates when a 401 is received. The JWT blacklist (database-backed) is enabled; logout invalidates the token immediately.
Auth payload (/me response)
toAuthPayload() shapes the object returned by GET /me and the login response. It includes the core identity fields, all Paddle subscription fields, 2FA status flags, and the permissions array (only populated for admin users: ['admin.access', 'users.manage']).
Relations
| Relation | Type | Pivot |
|---|---|---|
ownedWorkspaces() | belongsToMany Workspace | workspace_user |
ownedFinders() | belongsToMany Finder | finder_user |
ownedMaps() | belongsToMany Map | map_user |
ownedSets() | belongsToMany Set | set_user |
ownedLocations() | belongsToMany Location | location_user |
authSessions() | hasMany AuthSession | — |
Related pages
- API — Auth — login, register, JWT claims, 2FA endpoints
- Backend: Auth and roles — JWT middleware, role gate, tier gate
- Data model: Workspace
- Data model: PaddleWebhookEvent — where subscription updates originate