03 — Database Design
Back to README | Prev: Architecture | Next: Phase 1 Implementation
Overview
The database is split into two layers:
- Core tables (prefixed
laraschema_) — Ship with the core package. Required for the schema designer to function. - SaaS tables — Only exist in the hosted version. Handle auth, teams, billing, and versioning.
All core tables use the laraschema_ prefix to avoid collisions when the package is installed in an existing Laravel app.
Entity Relationship Diagram
users (SaaS only)
│
└── has many ──> projects
│
├── has many ──> tables
│ │
│ ├── has many ──> columns
│ └── has many ──> indexes
│
└── has many ──> relationships
│
├── belongs to ──> source_table (tables)
└── belongs to ──> target_table (tables)
teams (SaaS only)
│
├── has many ──> users (via team_user pivot)
└── has many ──> projects
Visual Diagram
+---------------------+ +---------------------+
| projects | | users |
+---------------------+ +---------------------+
| id PK |<──────| id PK |
| user_id FK | | name |
| team_id FK | | email |
| name | | password |
| description | | plan |
| canvas_settings J | +---------------------+
| is_public | │
| share_token | │
+---------------------+ │
│ │ │
│ │ +───────┼───────+
│ │ │ │
▼ ▼ ▼ ▼
+--------+ +---------------+ +-----+ +----------------+
| tables | | relationships | |teams| | team_user |
+--------+ +---------------+ +-----+ +----------------+
| id PK | | id PK | |id PK| | team_id FK |
|proj FK| | project_id FK | |name | | user_id FK |
| name | | type | |owner| | role |
| pos_x | | source_tbl FK | +-----+ +----------------+
| pos_y | | target_tbl FK |
| width | | source_col FK |
| color | | target_col FK |
| flags | | pivot_table |
+--------+ | on_delete |
│ | line_points J |
│ +---------------+
│
├──────────┬──────────┐
│ │
▼ ▼
+-----------+ +-----------+
| columns | | indexes |
+-----------+ +-----------+
| id PK | | id PK |
| table FK | | table FK |
| name | | name |
| type | | type |
| length | | col_ids J |
| nullable | +-----------+
| default |
| is_unique |
| is_index |
| enum_vals |
| sort_order|
+-----------+
Legend: PK = Primary Key, FK = Foreign Key, J = JSON column
Core Package Tables
These 8 tables ship with the core package.
laraschema_categories
Categories for organizing template / starter projects. Seeded by the package.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | bigIncrements | PK | |
| name | string(100) | required | Display name (e.g., "E-commerce") |
| slug | string(100) | required, unique | URL-safe slug |
| type | string(20) | default: "template", index | Category bucket (e.g., template) |
| sort_order | smallInteger | unsigned, default: 0 | Display order |
| created_at | timestamp | ||
| updated_at | timestamp |
laraschema_projects
The top-level container for a schema design.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | bigIncrements | PK | Auto-incrementing primary key |
| user_id | foreignId | nullable, index | Owner (null in package mode, set in SaaS) |
| guest_token | char(36) | nullable, index | Anonymous session token for guest visitors |
| guest_expires_at | timestamp | nullable | Expiry for guest-owned projects |
| team_id | foreignId | nullable, index | Team workspace (SaaS only) |
| name | string(255) | required | Project name (e.g., "E-Commerce App") |
| description | text | nullable | Optional project description |
| canvas_settings | json | nullable | Zoom level, pan position, grid settings |
| is_public | boolean | default: false | Whether schema is publicly viewable |
| is_template | boolean | default: false | Whether this project is a starter template |
| category_id | foreignId | nullable, FK to laraschema_categories, nullOnDelete |
Template category |
| share_token | string(64) | nullable, unique | Token for public share URL |
| created_at | timestamp | ||
| updated_at | timestamp | ||
| deleted_at | timestamp | nullable | Soft delete support |
laraschema_tables
Each visual table block on the canvas.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | bigIncrements | PK | |
| project_id | foreignId | required, index, cascade on delete | Parent project |
| name | string(255) | required | Table name (e.g., "users", "posts") |
| position_x | decimal(10,2) | default: 0 | X position on canvas |
| position_y | decimal(10,2) | default: 0 | Y position on canvas |
| width | decimal(10,2) | default: 250 | Table card width on canvas |
| color | string(7) | nullable | Hex color for header (e.g., "#3B82F6") |
| use_id | boolean | default: true | Auto-include $table->id() |
| use_timestamps | boolean | default: true | Auto-include $table->timestamps() |
| use_soft_deletes | boolean | default: false | Auto-include $table->softDeletes() |
| sort_order | integer | default: 0 | Display order in sidebar/list |
| is_system | boolean | default: false | Marks tables added by the system (e.g. auth scaffolding) |
| is_pivot | boolean | default: false | True for pivot tables created by belongsToMany |
| created_at | timestamp | ||
| updated_at | timestamp |
Unique constraint: [project_id, name] — no duplicate table names within a project.
Pivot tables: When a belongsToMany relationship is created, a real laraschema_tables row is inserted with is_pivot=true and linked from the relationship via pivot_table_id. The canvas renders these as dedicated pivot nodes.
laraschema_columns
Individual columns within a table.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | bigIncrements | PK | |
| table_id | foreignId | required, index, cascade on delete | Parent table |
| name | string(255) | required | Column name (e.g., "email", "title") |
| type | string(50) | required | Laravel column type (see Supported Column Types) |
| length | integer | nullable | Character length (e.g., 255 for string) |
| precision | integer | nullable | Decimal precision (e.g., 8 in decimal(8,2)) |
| scale | integer | nullable | Decimal scale (e.g., 2 in decimal(8,2)) |
| nullable | boolean | default: false | Whether column allows NULL |
| default_value | string(255) | nullable | Default value expression |
| is_unsigned | boolean | default: false | Unsigned integer |
| is_unique | boolean | default: false | Has unique constraint |
| is_index | boolean | default: false | Has regular index |
| is_primary | boolean | default: false | Is part of primary key |
| is_auto_increment | boolean | default: false | Auto-incrementing |
| is_foreign | boolean | default: false | Is a foreign key column |
| is_required | boolean | default: false | Flagged as required in UI / validation generation |
| enum_values | json | nullable | Values for enum/set types |
| comment | string(255) | nullable | Column comment |
| sort_order | integer | default: 0 | Display order within table |
| created_at | timestamp | ||
| updated_at | timestamp |
Unique constraint: [table_id, name] — no duplicate column names within a table.
laraschema_relationships
Connections between tables, representing both foreign keys and Eloquent relationships.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | bigIncrements | PK | |
| project_id | foreignId | required, index, cascade on delete | Parent project |
| name | string(255) | nullable | Custom relationship method name |
| type | string(30) | required | Eloquent relationship type (see Supported Relationship Types) |
| source_table_id | foreignId | required, index | The "from" table |
| target_table_id | foreignId | required, index | The "to" table |
| source_column_id | foreignId | nullable, nullOnDelete | Foreign key column on source |
| target_column_id | foreignId | nullable, nullOnDelete | Referenced column on target |
| pivot_table_id | foreignId | nullable, FK to laraschema_tables, nullOnDelete |
Linked pivot table row (belongsToMany) |
| pivot_table_name | string(255) | nullable | Pivot table name (belongsToMany) |
| pivot_source_key | string(255) | nullable | Source FK on pivot table |
| pivot_target_key | string(255) | nullable | Target FK on pivot table |
| on_delete | string(20) | default: "cascade" | cascade, restrict, set null, no action |
| on_update | string(20) | default: "cascade" | cascade, restrict, set null, no action |
| created_at | timestamp | ||
| updated_at | timestamp |
laraschema_indexes
Composite and special indexes on tables. Single-column indexes are handled via flags on the columns table.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | bigIncrements | PK | |
| table_id | foreignId | required, index, cascade on delete | Parent table |
| name | string(255) | nullable | Index name (auto-generated if null) |
| type | string(20) | required | index, unique, primary, fulltext, spatial |
| column_ids | json | required | Ordered array of column IDs in the index |
| created_at | timestamp | ||
| updated_at | timestamp |
laraschema_export_jobs
Tracks queued export jobs (ZIP/code generation) so the UI can show progress and the admin panel can monitor queue health.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | bigIncrements | PK | |
| ulid | char(26) | unique | Public-facing identifier used in URLs |
| project_id | foreignId | required, cascade on delete | Project being exported |
| type | string | required | Export type (e.g., full_project, code_only) |
| status | string | required | queued, processing, completed, failed |
| phase | string | nullable | Current pipeline step (for progress UI) |
| file_path | string | nullable | Path on disk to the generated artifact |
| error_message | text | nullable | Failure detail when status is failed |
| user_id | unsignedBigInteger | nullable, index | Owner (SaaS) |
| guest_token | char(36) | nullable, index | Guest visitor token (anonymous mode) |
| expires_at | timestamp | nullable | When the generated artifact will be purged |
| created_at | timestamp | ||
| updated_at | timestamp |
laraschema_feedback
In-app user feedback submitted via the feedback widget.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | bigIncrements | PK | |
| user_id | unsignedBigInteger | nullable, index | Authenticated submitter |
| guest_token | char(36) | nullable, index | Anonymous submitter token |
| category | string(20) | required | Feedback bucket (e.g., bug, idea, praise) |
| message | text | required | Free-text feedback |
| string(255) | nullable | Optional reply-to email | |
| page | string(255) | nullable | Page the feedback was submitted from |
| rating | tinyInteger | unsigned, nullable | Optional 1-5 rating |
| screenshot_path | string | nullable | Attached screenshot path |
| created_at | timestamp | ||
| updated_at | timestamp |
Supported Laravel Column Types
The columns.type field accepts all Laravel migration column types:
Numeric
tinyInteger, smallInteger, mediumInteger, integer, bigInteger,
unsignedTinyInteger, unsignedSmallInteger, unsignedMediumInteger, unsignedInteger, unsignedBigInteger,
decimal, double, float
String
char, string, tinyText, text, mediumText, longText
Date & Time
date, dateTime, dateTimeTz, time, timeTz,
timestamp, timestampTz, year
Binary & Boolean
binary, boolean
JSON
json, jsonb
UUID & ULID
uuid, ulid
Network
ipAddress, macAddress
Special
enum, set, foreignId, foreignUlid, foreignUuid,
morphs, nullableMorphs, ulidMorphs, uuidMorphs,
rememberToken, geography, geometry
Type Categories (used in code generation)
| Category | Types | Requires |
|---|---|---|
| Requires length | char, string |
length parameter |
| Requires precision | decimal, double, float |
precision and scale parameters |
| Requires values | enum, set |
enum_values JSON array |
| No parameters | All others | Nothing extra |
Supported Relationship Types
The relationships.type field accepts:
| Type | Description | FK Location |
|---|---|---|
hasOne |
One-to-one | FK on target table |
hasMany |
One-to-many | FK on target table |
belongsTo |
Inverse one-to-one/many | FK on source table |
belongsToMany |
Many-to-many | FK on pivot table |
hasOneThrough |
Has one through intermediate | Through intermediate table |
hasManyThrough |
Has many through intermediate | Through intermediate table |
morphOne |
Polymorphic one-to-one | morphs columns on target |
morphMany |
Polymorphic one-to-many | morphs columns on target |
morphToMany |
Polymorphic many-to-many | Pivot with morphs |
morphedByMany |
Inverse polymorphic many-to-many | Pivot with morphs |
FK Auto-Creation Rules
When a relationship is drawn between two tables, the system automatically creates the foreign key column:
belongsTo— FK on source table (e.g.,posts.user_id)hasOne/hasMany— FK on target table (e.g.,phones.user_id)belongsToMany— Creates a real pivotTable(is_pivot = true) holding both FKs; neither parent gets a column. A self-referential pivot usesparent_id/child_id, since deriving both keys from one table name would collapse them into a single column.morphOne/morphMany— Creates the{morph}_type/{morph}_idpair on the target (e.g.comments.postable_type,comments.postable_id). Neither column is markedis_foreign: a morph id deliberately points at more than one table, so it carries no FK constraint.morphToMany/morphedByMany— Creates a pivotTableholding the morph pair plus one plain FK.morphedByManyis the inverse side, so its morph columns are named after the target table. By default the pivot name is derived from the morphable table (posts→postables); pass an explicit pivot name to use the shared Laravel convention instead, wheretaggablesimplies the morph nametaggable.
hasOneThrough and hasManyThrough are the two types in the table above that are not creatable — there is nowhere in the data model to record the intermediate table. RelationshipType::mvpTypes() is the authoritative list of the eight that are, and it drives both the canvas editor's picker and the MCP create_relationship schema.
These rules are applied by LaraSchema\Services\SchemaMutator, which is the single write path behind the MCP tools and the AI assistant's apply(). The canvas editor (RelationshipManager) mirrors them.
SaaS-Only Tables
These tables only exist in the hosted SaaS application — they are not part of the core package.
users
Standard Laravel users table (via Laravel Breeze).
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | bigIncrements | PK | |
| name | string(255) | required | |
| string(255) | required, unique | ||
| email_verified_at | timestamp | nullable | |
| password | string(255) | required | |
| avatar_url | string(255) | nullable | Profile picture |
| plan | string(20) | default: "free" | free, pro, team |
| remember_token | string(100) | nullable | |
| created_at | timestamp | ||
| updated_at | timestamp |
teams
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | bigIncrements | PK | |
| name | string(255) | required | Team name |
| owner_id | foreignId | required, index | Team creator/owner |
| created_at | timestamp | ||
| updated_at | timestamp |
team_user (pivot)
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | bigIncrements | PK | |
| team_id | foreignId | required, cascade on delete | |
| user_id | foreignId | required, cascade on delete | |
| role | string(20) | default: "member" | owner, admin, member |
| created_at | timestamp | ||
| updated_at | timestamp |
Unique constraint: [team_id, user_id]
schema_versions
Version history snapshots for projects.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | bigIncrements | PK | |
| project_id | foreignId | required, index, cascade on delete | |
| version_number | integer | required | Auto-incrementing per project |
| snapshot | json | required | Full schema state at point in time |
| description | string(255) | nullable | Version description / commit message |
| created_by | foreignId | nullable | User who created this version |
| created_at | timestamp |
Unique constraint: [project_id, version_number]
project_shares
Granular sharing permissions.
| Column | Type | Constraints | Description |
|---|---|---|---|
| id | bigIncrements | PK | |
| project_id | foreignId | required, index, cascade on delete | |
| user_id | foreignId | nullable, cascade on delete | Shared with specific user |
| string(255) | nullable | Shared via email (user may not exist yet) | |
| permission | string(20) | default: "view" | view, edit, admin |
| token | string(64) | unique | Share link token |
| expires_at | timestamp | nullable | Optional expiration |
| created_at | timestamp | ||
| updated_at | timestamp |
Design Decisions
| Decision | Rationale |
|---|---|
laraschema_ table prefix |
Avoids collisions when package is installed in existing Laravel apps |
JSON for canvas_settings |
Flexible storage for zoom, pan, grid — schema may evolve without new migrations |
JSON for column_ids in indexes |
Composite indexes have ordered columns; array preserves order |
Real pivot rows (is_pivot) |
Pivot tables are first-class table rows so they render on the canvas and own columns/indexes |
guest_token + expires_at |
Lets anonymous visitors use the tool with time-limited ownership, then claim into a user account at signup |
Dedicated export_jobs table |
Long-running ZIP exports run on the queue; the row drives the progress UI and admin queue-health view |
| Soft deletes on projects only | Allow project recovery; tables/columns cascade-delete with project |
share_token on projects |
Simple public sharing without full share management infrastructure |
| Separate indexes table | Single-column indexes use column flags; composite indexes need their own storage |
sort_order on tables and columns |
Preserves user's preferred ordering in sidebar and within table cards |
user_id nullable on projects |
Package mode has no users; SaaS mode always sets user_id |
snapshot as JSON in schema_versions |
Captures complete state for easy point-in-time restoration |
Next: Phase 1 Implementation