Build Log
Detailed record of every build step β what was done, what commands were run, what decisions were made, and what the outcome was. Useful for debugging, onboarding, and reproducing the setup.
Step 1: Project Scaffolding
Date: 2026-02-12 Status: Complete
1.1 Environment
| Tool | Version |
|---|---|
| PHP | 8.4.6 |
| Composer | 2.7.1 |
| Node.js | 20.20.0 |
| npm | 10.8.2 |
| Laravel Installer | 5.11.2 |
1.2 Create Laravel 12 App
Command:
laravel new laraschema_temp --pest --no-interaction
Outcome: Created Laravel 12.51.0 app with Pest testing framework in a temp folder. Then moved contents into LaraSchema/ while preserving existing documentation files (PLANNING.md, README.md, docs/).
What shipped with Laravel 12:
- Tailwind CSS 4 (via
@tailwindcss/viteplugin) - Vite build system
- SQLite as default database
- Pest PHP 4.3 for testing
1.3 Install Livewire 4
Command:
composer require livewire/livewire
Outcome: Installed livewire/livewire v4.1. Auto-discovered by Laravel.
1.4 Configure Tailwind for Package Views
File modified: resources/css/app.css
Change: Added @source directive so Tailwind scans our package's Blade views for utility classes:
@source '../../packages/laraschema/resources/views/**/*.blade.php';
Why: Without this, any Tailwind classes used in packages/laraschema/resources/views/ would be purged during production builds because Tailwind wouldn't know they exist.
1.5 Create Package Directory Structure
Created under packages/laraschema/:
packages/laraschema/
βββ composer.json
βββ config/
βββ database/migrations/
βββ resources/views/
β βββ layouts/
β βββ livewire/
βββ routes/
βββ src/
βββ LaraSchemaServiceProvider.php (placeholder)
βββ Enums/
βββ Generators/
βββ Http/
β βββ Controllers/
β βββ Livewire/
βββ Models/
βββ Services/
1.6 Package composer.json
File: packages/laraschema/composer.json
Key settings:
- Name:
laraschema/laraschema - PSR-4 Autoload:
LaraSchema\\maps tosrc/ - Laravel Auto-discovery: Registers
LaraSchema\LaraSchemaServiceProviderautomatically - Dependencies:
illuminate/support ^12.0,livewire/livewire ^4.0
1.7 Wire Up Monorepo
File modified: Root composer.json
Changes:
- Added path repository pointing to
packages/laraschema - Added
"laraschema/laraschema": "@dev"torequire - Changed
minimum-stabilityfrom"stable"to"dev"(required for@devpackages)
Path repository config:
"repositories": [
{
"type": "path",
"url": "packages/laraschema"
}
]
1.8 Composer Update
Command:
composer update
Outcome:
- Package symlinked:
vendor/laraschema/laraschema -> ../../packages/laraschema/ - Auto-discovery confirmed:
laraschema/laraschema ... DONE - No errors
1.9 Verification
| Check | Result |
|---|---|
php artisan --version |
Laravel Framework 12.51.0 |
| Package symlink exists | vendor/laraschema/laraschema -> ../../packages/laraschema/ |
| Package auto-discovered | laraschema/laraschema ... DONE |
| Pest tests run | 1 passed, 1 failed (default welcome page test β expected) |
Tailwind @source added |
Package views will be scanned |
Files Created/Modified in Step 1
| File | Action | Purpose |
|---|---|---|
packages/laraschema/composer.json |
Created | Package metadata, autoload, auto-discovery |
packages/laraschema/src/LaraSchemaServiceProvider.php |
Created | Placeholder service provider (full version in Step 2) |
composer.json (root) |
Modified | Added path repository + package requirement |
resources/css/app.css |
Modified | Added @source for package views |
Step 2: Package Foundation
Date: 2026-02-12 Status: Complete
2.1 Config β packages/laraschema/config/laraschema.php
Published configuration with three sections:
| Key | Default | Purpose |
|---|---|---|
route_prefix |
"laraschema" |
URL prefix for all package routes |
middleware |
["web"] |
Middleware stack applied to routes |
default_canvas.zoom |
1 |
Initial zoom level for new projects |
default_canvas.pan_x |
0 |
Initial horizontal pan offset |
default_canvas.pan_y |
0 |
Initial vertical pan offset |
default_canvas.grid_size |
20 |
Grid spacing in pixels |
default_canvas.snap_to_grid |
true |
Whether dragged tables snap to grid |
default_table.width |
250 |
Default table card width on canvas |
default_table.color |
"#3B82F6" |
Default table header color (blue) |
Publishable via php artisan vendor:publish --tag=laraschema-config
2.2 Routes β packages/laraschema/routes/web.php
| Method | URI | Name | Handler |
|---|---|---|---|
| GET | /laraschema |
laraschema.projects |
ProjectManager (Livewire) |
| GET | /laraschema/project/{project} |
laraschema.canvas |
Canvas (Livewire) |
| GET | /laraschema/project/{project}/export |
laraschema.export |
ExportController@download |
Routes are wrapped with configurable prefix and middleware by the service provider.
2.3 Enums
ColumnType β 35 cases covering all Laravel migration column types.
- Grouped into 8 categories: Numeric, String, Date & Time, Binary & Boolean, JSON, UUID & ULID, Network, Special
- Helper methods:
requiresLength(),requiresPrecision(),requiresValues(),label(),category() - Static
grouped()method returns types organized by category for UI dropdowns
RelationshipType β 10 cases for all Eloquent relationship types.
- Helper methods:
label(),description(),foreignKeyLocation(),requiresPivot(),sourceCardinality(),targetCardinality() - Static
mvpTypes()returns the 4 types used in Phase 1 (hasOne, hasMany, belongsTo, belongsToMany)
2.4 Migrations
All 5 ran successfully:
| Migration | Table | Time |
|---|---|---|
2026_01_01_000001_create_laraschema_projects_table |
laraschema_projects |
20.52ms |
2026_01_01_000002_create_laraschema_tables_table |
laraschema_tables |
9.88ms |
2026_01_01_000003_create_laraschema_columns_table |
laraschema_columns |
10.93ms |
2026_01_01_000004_create_laraschema_relationships_table |
laraschema_relationships |
8.04ms |
2026_01_01_000005_create_laraschema_indexes_table |
laraschema_indexes |
6.50ms |
Key constraints:
laraschema_tableshas unique constraint on[project_id, name]laraschema_columnshas unique constraint on[table_id, name]- All child tables cascade-delete when parent is removed
laraschema_projectssupports soft deletes
2.5 Models
All 5 models created with proper relationships, casts, and $fillable:
| Model | Table | Key Relationships | Key Casts |
|---|---|---|---|
Project |
laraschema_projects |
hasMany tables, hasMany relationships | canvas_settings β array, is_public β boolean |
Table |
laraschema_tables |
belongsTo project, hasMany columns, hasMany indexes | decimals, booleans, integer |
Column |
laraschema_columns |
belongsTo table | enum_values β array, 8 boolean casts |
Relationship |
laraschema_relationships |
belongsTo project, sourceTable, targetTable, sourceColumn, targetColumn | line_points β array |
Index |
laraschema_indexes |
belongsTo table | column_ids β array |
2.6 Service Provider β LaraSchemaServiceProvider.php
Full implementation replacing the Step 1 placeholder:
| Method | What it does |
|---|---|
register() |
Merges config from config/laraschema.php |
loadRoutes() |
Registers routes with configurable prefix + middleware |
loadViews() |
Loads views namespaced as laraschema:: |
loadMigrations() |
Auto-loads migrations from package database/migrations/ |
registerLivewireComponents() |
Registers namespace laraschema pointing to src/Http/Livewire/ |
registerPublishing() |
Publishes config and migrations when running in console |
2.7 Layout β resources/views/layouts/app.blade.php
- Uses
@vite()from the host app for CSS + JS - Includes
@livewireStylesand@livewireScripts - Top nav bar with LaraSchema icon (SVG grid) and branding
{{ $actions ?? '' }}slot for page-specific toolbar buttons{{ $slot }}for main content- Full viewport, light gray background, antialiased text
2.8 Placeholder Components
Created minimal Livewire components to make routes functional:
ProjectManagerβ Renders placeholder view at/laraschemaCanvasβ AcceptsProjectmodel, renders placeholder at/laraschema/project/{project}ExportControllerβ Returns 501 "not yet implemented" (full version in Step 8)
2.9 Verification
| Check | Result |
|---|---|
php artisan migrate |
All 5 laraschema_* tables created |
php artisan route:list --path=laraschema |
3 routes registered correctly |
| Config via tinker | All settings load with correct defaults |
npm run build |
Vite assets built (CSS 50.8KB, JS 36.7KB) |
Visit /laraschema |
Page renders: nav bar + "Projects" heading + placeholder |
| Model CRUD via tinker | Project β Table β Column creation, relationships, and enum helpers all work |
Tinker test output:
Project created: 1
Table created: 1
Column created: 1
Tables count: 1
Columns count: 1
String requires length: yes
Decimal requires precision: yes
Enum requires values: yes
BelongsToMany requires pivot: yes
HasMany FK location: target
Files Created in Step 2
| File | Purpose |
|---|---|
packages/laraschema/config/laraschema.php |
Package configuration |
packages/laraschema/routes/web.php |
Route definitions (3 routes) |
packages/laraschema/src/Enums/ColumnType.php |
35 Laravel column types with helpers |
packages/laraschema/src/Enums/RelationshipType.php |
10 relationship types with helpers |
packages/laraschema/database/migrations/* |
5 migration files |
packages/laraschema/src/Models/Project.php |
Project model (SoftDeletes) |
packages/laraschema/src/Models/Table.php |
Table model |
packages/laraschema/src/Models/Column.php |
Column model |
packages/laraschema/src/Models/Relationship.php |
Relationship model |
packages/laraschema/src/Models/Index.php |
Index model |
packages/laraschema/src/LaraSchemaServiceProvider.php |
Full service provider |
packages/laraschema/resources/views/layouts/app.blade.php |
Layout template |
packages/laraschema/src/Http/Livewire/ProjectManager.php |
Placeholder component |
packages/laraschema/src/Http/Livewire/Canvas.php |
Placeholder component |
packages/laraschema/resources/views/livewire/project-manager.blade.php |
Placeholder view |
packages/laraschema/resources/views/livewire/canvas.blade.php |
Placeholder view |
packages/laraschema/src/Http/Controllers/ExportController.php |
Placeholder controller |
Step 3: Project Management (CRUD)
Date: 2026-02-12 Status: Complete
3.1 ProjectManager Livewire Component
File: packages/laraschema/src/Http/Livewire/ProjectManager.php
Full CRUD component replacing the Step 2 placeholder. Uses WithPagination trait.
| Method | Action | Validation |
|---|---|---|
openCreateModal() |
Opens create modal, resets form state | β |
closeCreateModal() |
Closes modal, clears inputs + validation errors | β |
createProject() |
Creates project with default canvas settings | name: required, min:2, max:255; description: nullable, max:1000 |
startRenaming($id) |
Enters inline rename mode, pre-fills current name | β |
cancelRenaming() |
Exits rename mode | β |
saveRename() |
Persists new name | name: required, min:2, max:255 |
confirmDelete($id) |
Opens delete confirmation modal | β |
cancelDelete() |
Cancels delete | β |
deleteProject() |
Soft-deletes the project | β |
render() |
Returns paginated projects (12 per page) with table counts | β |
Key decisions:
- Projects are paginated at 12 per page (fits 3x4 grid nicely)
withCount('tables')used for efficient table count display without N+1- New projects get
canvas_settingsfrom config defaults - Delete uses soft delete (via the
SoftDeletestrait on the model)
3.2 Project Manager View
File: packages/laraschema/resources/views/livewire/project-manager.blade.php
UI layout:
ββββββββββββββββββββββββββββββββββββββββββββββββ
β Projects [+ New Project] β
β Design and manage your database schemas. β
ββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β ββββββββββββ ββββββββββββ ββββββββββββ β
β βE-Commerceβ βBlog β βCRM β β
β βOnline... β βMulti... β β β β
β β3 tables β β1 table β β0 tables β β
β β2m ago β β2m ago β β2m ago β β
β β [β][π]β β [β][π]β β [β][π]β β
β ββββββββββββ ββββββββββββ ββββββββββββ β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββ
Components:
- Header section: Title, subtitle, "New Project" button (blue, top-right)
- Card grid: Responsive 1/2/3 columns (sm/md/lg), rounded-xl cards with hover shadow
- Each card contains:
- Clickable body (links to canvas designer)
- Project name (or inline rename input when editing)
- Description (line-clamped to 2 lines)
- Meta: table count with icon + relative timestamp
- Footer: Rename (pencil) and Delete (trash) icon buttons
- Empty state: Database icon, "No projects yet" message, CTA button
- Create modal: Name input (auto-focused) + description textarea, Cancel/Create buttons
- Delete modal: Warning icon, project name in bold, explains cascading impact, Cancel/Delete buttons
Alpine.js integration:
x-on:keydown.escapecloses modalsx-init="$el.focus(); $el.select()"on rename input auto-focuses and selects textx-on:click.stopon rename input prevents card click navigation
3.3 Verification
| Check | Result |
|---|---|
| Empty state | Shows "No projects yet" with CTA button |
| Create project | Modal opens, validation works, project appears in grid |
| Project card | Shows name, description (truncated), table count, timestamp |
| Table count | Correct β "3 tables", "1 table", "0 tables" with proper pluralization |
| Card click | Navigates to /laraschema/project/{id} (canvas placeholder) |
| Rename | Inline input appears with current name, Enter saves, Escape cancels |
| Delete | Confirmation modal with project name, deletes on confirm |
| Pagination | 12 cards per page |
| Responsive | 1 column mobile, 2 tablet, 3 desktop |
Test data created:
- "E-Commerce App" (3 tables: users, products, orders)
- "Blog Platform" (1 table: posts)
- "CRM System" (0 tables)
Files Modified in Step 3
| File | Action | Purpose |
|---|---|---|
packages/laraschema/src/Http/Livewire/ProjectManager.php |
Replaced | Full CRUD component with pagination |
packages/laraschema/resources/views/livewire/project-manager.blade.php |
Replaced | Card grid, modals, empty state, inline rename |
Steps 4-5: Canvas + Table Nodes
Date: 2026-02-12 Status: Complete
4.1 Canvas Livewire Component
File: packages/laraschema/src/Http/Livewire/Canvas.php
Replaced the Step 2 placeholder with full implementation. This component is the server-side backbone β it handles data persistence while Alpine.js handles all visual interactions.
| Method | Purpose | Called By |
|---|---|---|
mount($project) |
Eager-loads tables, columns, relationships | Livewire |
addTable() |
Creates table with auto-generated name and staggered position | Toolbar button |
updateTablePosition($id, $x, $y) |
Persists position after drag ends | Alpine.js $wire |
updateCanvasSettings($settings) |
Persists zoom/pan state (debounced) | Alpine.js $wire |
selectTable($id) |
Opens side panel for editing | Alpine.js double-click |
deselectTable() |
Closes side panel | Escape key / close button |
deleteTable($id) |
Removes table with cascade, dispatches event | TableEditor |
onTableUpdated() |
Reloads project data when editor makes changes | Livewire event |
getTablesForAlpine() |
Formats all tables as JSON for Alpine state | render() |
getRelationshipsForAlpine() |
Formats relationships as JSON for Alpine | render() |
Key decisions:
addTable()generates unique names (new_table,new_table_1, etc.) and positions in a 4-column staggered grid- New table positions snap to the grid automatically
formatTableForAlpine()casts all values to proper JS types (float, bool) to avoid Alpine issues- Dynamic title via
->title($project->name . ' β LaraSchema')
4.2 Canvas Blade View + Alpine.js Component
File: packages/laraschema/resources/views/livewire/canvas.blade.php
This is the most complex file in the project. It contains three main sections:
Toolbar (HTML overlay)
- Back arrow link to projects
- Project name display
- "Add Table" button (blue, calls
wire:click="addTable") - Zoom controls:
-, percentage display (clickable to reset),+ - Snap-to-grid toggle (highlights blue when active)
SVG Canvas
- Wrapped in
wire:ignoreto prevent Livewire DOM-diffing during interactions - Grid background: Two
<pattern>elements βminor-grid(every gridSize px) andmajor-grid(every 5x gridSize px) - Transform group:
<g :transform="translate(panX, panY) scale(zoom)">applies pan + zoom - Relationship lines:
<template x-for>rendering<line>elements between table edges - Table nodes:
<template x-for="table in tables">rendering card-like<g>groups:- Shadow rect (2px offset, subtle)
- White card background with gray border (blue border when selected)
- Colored header rect with table name text
- Built-in field rows:
id(italic, gray), user columns,timestamps,softDeletes - Column rows: name on left (with
?nullable andFKindicators), type on right - Dynamic height calculated from row count
Side Panel
- Slides in from right when a table is double-clicked (
w-96) - Alpine.js transition: slide in/out with opacity
- Contains a nested
<livewire:laraschema.table-editor>component (placeholder for Step 6) - Close button + Escape key to dismiss
Alpine.js schemaCanvas Component (inline <script>)
| Feature | How It Works |
|---|---|
| Pan | Mousedown on SVG background captures start position; mousemove applies delta to panX/panY; mouseup persists via debounced $wire.updateCanvasSettings() |
| Zoom | Scroll wheel adjusts zoom (0.2β3.0 range); zooms toward mouse cursor by adjusting pan proportionally; button controls for +/- |
| Drag | Mousedown on table node captures offset via clientToSvg(); mousemove updates position (with snap-to-grid); mouseup calls $wire.updateTablePosition() |
| Coordinate conversion | clientToSvg() converts screen coordinates to SVG space: (clientX - rect.left - panX) / zoom |
| Grid snapping | Math.round(pos / gridSize) * gridSize β toggleable via toolbar |
| Livewire sync | $wire.$on('table-added', ...) pushes new tables into Alpine state without re-render; $wire.$on('table-deleted', ...) removes them |
| Debounced persistence | Canvas settings (zoom/pan) debounce 500ms before saving to avoid flooding the server |
Table node rendering breakdown:
ββββββββββββββββββββββββββββββββββββ
β ββββββ table_name βββββββββββββββ <- colored header (36px)
ββββββββββββββββββββββββββββββββββββ€
β id bigIncrementsβ <- built-in (italic, gray)
ββββββββββββββββββββββββββββββββββββ€
β email ? string β <- column (? = nullable)
β user_id FK string β <- column (FK indicator)
ββββββββββββββββββββββββββββββββββββ€
β timestamps created/updatedβ <- built-in (italic, gray)
β softDeletes deleted_at β <- built-in (italic, gray)
ββββββββββββββββββββββββββββββββββββ
Each row is 28px high. Total height = 36 (header) + rows Γ 28 + 12 (padding).
4.3 Placeholder Components
Created minimal TableEditor component so double-click doesn't crash:
packages/laraschema/src/Http/Livewire/TableEditor.phpβ loads table by IDpackages/laraschema/resources/views/livewire/table-editor.blade.phpβ shows table name + "coming in Step 6"
4.4 Verification
| Check | Result |
|---|---|
| Page loads | "E-Commerce App β LaraSchema" title, toolbar renders |
| Table data | 3 tables (users, products, orders) passed as JSON via @js() |
| SVG grid | Minor + major grid patterns defined |
wire:ignore |
Present on SVG container |
| Alpine.js methods | startPan, startDrag, onWheel, clientToSvg all in source |
| Zoom controls | -, 100%, + buttons present |
| Snap-to-grid | Toggle button present, highlighted by default |
| Add Table button | wire:click="addTable" wired up |
| Side panel | Conditional render with slide transition |
| Asset build | CSS grew from 54.4KB to 57.6KB (canvas classes picked up) |
Files Created/Modified in Steps 4-5
| File | Action | Purpose |
|---|---|---|
packages/laraschema/src/Http/Livewire/Canvas.php |
Replaced | Full canvas component with table CRUD + Alpine sync |
packages/laraschema/resources/views/livewire/canvas.blade.php |
Replaced | SVG canvas, toolbar, Alpine.js schemaCanvas component |
packages/laraschema/src/Http/Livewire/TableEditor.php |
Created | Placeholder for Step 6 |
packages/laraschema/resources/views/livewire/table-editor.blade.php |
Created | Placeholder for Step 6 |
Step 6: Table Editor Panel
Date: 2026-02-12 Status: Complete
6.1 TableEditor Livewire Component
File: packages/laraschema/src/Http/Livewire/TableEditor.php
Replaced the Step 4-5 placeholder with full implementation. This component handles all table settings and column CRUD operations via the side panel.
Properties:
| Property | Type | Purpose |
|---|---|---|
$table |
Table |
The table model being edited |
$tableName |
string |
Bound to table name input |
$tableColor |
string |
Bound to color picker (hex) |
$useId |
bool |
Toggle for id() column |
$useTimestamps |
bool |
Toggle for timestamps() |
$useSoftDeletes |
bool |
Toggle for softDeletes() |
$newColumnName |
string |
Add column form: name input |
$newColumnType |
string |
Add column form: type dropdown |
$newColumnNullable |
bool |
Add column form: nullable checkbox |
$newColumnDefault |
string |
Add column form: default value |
$newColumnUnique |
bool |
Add column form: unique checkbox |
$newColumnIndex |
bool |
Add column form: index checkbox |
$confirmingColumnDeleteId |
?int |
Column ID pending delete confirmation |
$confirmingTableDelete |
bool |
Whether table delete confirmation is showing |
Methods:
| Method | Purpose | Validation |
|---|---|---|
mount($tableId) |
Loads table with columns, syncs state | β |
updateTableName() |
Persists renamed table | snake_case regex, unique per project |
updateTableColor() |
Persists new header color | Hex color regex /^#[0-9A-Fa-f]{6}$/ |
toggleUseId() |
Toggles id() column | β |
toggleUseTimestamps() |
Toggles timestamps() | β |
toggleUseSoftDeletes() |
Toggles softDeletes() | β |
addColumn() |
Creates column with sort_order | snake_case, unique per table, type required |
updateColumn($id, $field, $val) |
Inline-edits a column field | Whitelist of 11 allowed fields |
confirmColumnDelete($id) |
Shows delete confirmation inline | β |
cancelColumnDelete() |
Hides confirmation | β |
deleteColumn() |
Deletes the confirmed column | β |
moveColumnUp($id) |
Swaps sort_order with previous | β |
moveColumnDown($id) |
Swaps sort_order with next | β |
confirmTableDelete() |
Shows table delete confirmation | β |
cancelTableDelete() |
Hides confirmation | β |
deleteTable() |
Deletes table, dispatches table-deleted-from-editor |
β |
dispatchTableUpdated() |
Dispatches table-updated event with full formatted data |
β |
getColumnTypesProperty() |
Computed property β ColumnType::grouped() |
β |
Key decisions:
updateColumn()uses an allowlist of 11 fields for security β only these can be updated: name, type, nullable, default_value, is_unique, is_index, is_unsigned, length, precision, scale, comment- Column name validation applies snake_case regex and uniqueness per table (same as
addColumn()) dispatchTableUpdated()sends a structured array (not a model) to avoid serialization issues between Livewire and Alpine.jsdeleteTable()dispatches toCanvas::classspecifically (not broadcast) so only the canvas reacts- After every mutation,
$this->table->load('columns')is called to refresh the column collection
6.2 Table Editor Blade View
File: packages/laraschema/resources/views/livewire/table-editor.blade.php
The view is organized into 4 distinct sections separated by <hr> dividers:
ββββββββββββββββββββββββββββββββ
β TABLE SETTINGS β
β ββββββββββββββββββββββββββ β
β β Name: [users_______] β β
β β Color: [β ] #3B82F6 β β
β β id() [=====] β β
β β timestamps() [=====] β β
β β softDeletes() [=====] β β
β ββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββ€
β COLUMNS (3) β
β ββββββββββββββββββββββββββ β
β β β² name string β β β
β β βΌ βNull βUnique βIdx β β
β ββββββββββββββββββββββββββ€ β
β β β² email string β β β
β β βΌ βNull βUnique βIdx β β
β ββββββββββββββββββββββββββ€ β
β β β² password string β β β
β β βΌ βNull βUnique βIdx β β
β ββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββ€
β ADD COLUMN β
β [column_name_____________] β
β [string βΌ ] β
β [Default value (optional)] β
β β Nullable β Unique β Idx β
β [+ Add Column ] β
ββββββββββββββββββββββββββββββββ€
β [π Delete Table ] β
ββββββββββββββββββββββββββββββββ
Section details:
-
Table Settings β Name input (
wire:model.blur+wire:changefor debounced save), native<input type="color">, three custom toggle switches styled as blue/gray pills with animated knob transition -
Columns List β Each column is a card with:
- Reorder arrows (β²/βΌ) β hidden when at top/bottom boundary
- Inline name input (
wire:blurfor update on focus loss) - Type dropdown with
<optgroup>categories fromColumnType::grouped() - Delete button (appears on hover via
group-hover:opacity-100) - Modifier checkboxes (Null, Unique, Index) below
- Delete confirmation replaces the row with a red banner + Yes/No buttons
-
Add Column Form β
wire:submit="addColumn", includes name input, type select (with type labels), default value input, modifier checkboxes, and submit button -
Delete Table β Red outlined button expands to confirmation box with warning about cascading deletes
UI polish details:
- Toggle switches use
:class="@js($var)"for reactive Alpine-in-Blade state - Column rows use
groupclass for hover-reveal delete button - Font-mono on name inputs for code-like appearance
wire:key="col-{{ $column->id }}"for correct Livewire diffing- Validation errors show beneath relevant inputs in red
6.3 Verification
| Check | Result |
|---|---|
| Assets rebuilt | CSS 61.7KB (grew from 57.6KB β new table editor classes picked up) |
| Component file | Full implementation with all 17 methods |
| View file | 311 lines, 4 sections, all wire bindings correct |
| Column types dropdown | Uses $this->columnTypes computed property with <optgroup> |
| Event dispatch | table-updated sends structured array with columns to Canvas |
| Delete flow | Two-step: confirm β delete for both columns and table |
| Reorder | moveColumnUp/moveColumnDown swap sort_order values |
Files Modified in Step 6
| File | Action | Purpose |
|---|---|---|
packages/laraschema/src/Http/Livewire/TableEditor.php |
Replaced | Full table editor with column CRUD, reorder, settings |
packages/laraschema/resources/views/livewire/table-editor.blade.php |
Replaced | Settings panel, column list, add form, delete section |
Step 7: Relationships
Date: 2026-02-12 Status: Complete
7.1 RelationshipManager Livewire Component
File: packages/laraschema/src/Http/Livewire/RelationshipManager.php
Handles all relationship CRUD operations including auto-creation of FK columns.
Properties:
| Property | Type | Purpose |
|---|---|---|
$project |
Project |
The project being edited |
$sourceTableId |
?int |
Selected source table for new relationship |
$targetTableId |
?int |
Selected target table for new relationship |
$relationshipType |
string |
Selected type (default: hasMany) |
$onDelete |
string |
On-delete action (default: cascade) |
$onUpdate |
string |
On-update action (default: cascade) |
$showCreateModal |
bool |
Whether the create modal is visible |
$confirmingDeleteId |
?int |
Relationship ID pending delete confirmation |
Methods:
| Method | Purpose |
|---|---|
mount($project) |
Loads project with tables, columns, relationships |
onOpenRelationshipModal($src, $tgt) |
#[On] listener for Alpine.js dispatched event |
openCreateModal($src, $tgt) |
Opens create modal with pre-selected tables |
closeCreateModal() |
Closes modal, resets form state |
createRelationship() |
Creates relationship + auto-creates FK column |
confirmDelete($id) |
Shows delete confirmation |
cancelDelete() |
Hides confirmation |
deleteRelationship() |
Deletes relationship + removes FK column |
getRelationshipTypesProperty() |
Computed: MVP types with labels/descriptions |
getDeleteActionsProperty() |
Computed: cascade, restrict, set null, no action |
createForeignKeyColumn($on, $ref, $del) |
Creates FK column on the correct table |
dispatchTableUpdated($table) |
Dispatches table-updated event to refresh canvas |
FK column auto-creation logic:
| Relationship Type | FK Location | FK Column Created |
|---|---|---|
belongsTo |
Source table | {singular_target}_id on source |
hasOne / hasMany |
Target table | {singular_source}_id on target |
belongsToMany |
Pivot table | No FK column; stores pivot table name instead |
Key decisions:
- FK columns are created as
unsignedBigIntegerwithis_foreign = trueandis_index = true - If
onDeleteisset null, the FK column is made nullable automatically - If a column with the FK name already exists, it's reused and marked as foreign
- Pivot table names follow Laravel convention: alphabetically sorted singular names joined with underscore
- Both
relationship-createdandtable-updatedevents are dispatched so the canvas updates both the line and the table node
7.2 Relationship Manager Blade View
File: packages/laraschema/resources/views/livewire/relationship-manager.blade.php
Two main sections:
-
Create Relationship Modal (shown when
$showCreateModalis true):- Header showing source β target table names
- Radio button group for relationship types (4 MVP types with descriptions)
- On-delete and on-update dropdowns
- FK column preview showing what will be auto-created
- Pivot table name preview for belongsToMany
- Cancel / Create buttons
-
Relationships List (always visible in side panel):
- Each relationship shows: source name β type badge β target name
- Metadata: on_delete action, pivot table name (if applicable)
- Delete button (hover-reveal) with inline Yes/No confirmation
- Empty state: "No relationships yet" message
7.3 Canvas Integration
Updated files:
Canvas.phpβ Added event listeners and cardinality datacanvas.blade.phpβ Major update for relationship mode
Canvas.php changes:
| Addition | Purpose |
|---|---|
use RelationshipType |
Import for cardinality lookup |
#[On('table-deleted-from-editor')] |
Handles table deletion from editor panel |
#[On('relationship-created')] |
Refreshes project data when relationship created |
#[On('relationship-deleted')] |
Refreshes project data when relationship deleted |
getRelationshipsForAlpine() updated |
Now includes source_cardinality and target_cardinality |
Canvas Blade view changes:
Toolbar additions:
- "Draw Relationship" button (amber when active) with dynamic text: "Draw Relationship" β "Click Source..." β "Click Target..."
- Add Table button disabled (opacity-50) during relationship mode
Relationship Mode Banner:
- Floating amber banner centered at top with instructions
- Shows "Click the source table to start" or "Now click the target table"
- Cancel button to exit mode
SVG enhancements:
- Cardinality markers: 4 SVG
<marker>definitions (one-start, one-end, many-start, many-end)- "1" marker: vertical line
- "Many" marker: crow's foot (3 fanning lines)
- Relationship paths: Changed from
<line>to<path>using cubic bezier curves for smoother routing - Relationship labels: Type name displayed at midpoint of each line
- Preview line: Dashed amber line from source table center to mouse cursor during drawing
- Source highlight: Selected source table gets amber dashed border
- Target hints: All other tables get subtle amber border during relationship mode
Alpine.js additions:
| Feature | Implementation |
|---|---|
relationshipMode |
Boolean flag for draw-relationship mode |
relationshipSourceId |
ID of first-clicked table (source) |
relationshipPreviewX/Y |
Mouse position for preview line |
toggleRelationshipMode() |
Enters/exits relationship mode |
cancelRelationshipMode() |
Resets all relationship mode state |
onRelationshipTableClick(id) |
Handles source/target selection flow |
onTableMouseDown($event, table) |
Routes clicks to drag OR relationship mode |
onEscape() |
Exits relationship mode first, then deselects table |
getRelSourceCenter() |
Returns center point of source table for preview line |
getRelationshipPath(rel) |
Generates cubic bezier SVG path |
getRelationshipLabelPos(rel) |
Returns midpoint for relationship type label |
| Relationship event listeners | relationship-created pushes to array, relationship-deleted filters out |
| Table deletion cleanup | Removes associated relationships from Alpine state |
Cursor states:
- Default:
cursor-grab - Panning:
cursor-grabbing - Relationship mode:
cursor-crosshair
7.4 User Flow
- Click "Draw Relationship" button β toolbar button turns amber, banner appears
- Click source table β amber dashed border appears, preview line follows mouse
- Click target table β relationship modal opens with source/target pre-filled
- Select type (hasOne, hasMany, belongsTo, belongsToMany), on_delete, on_update
- See FK column preview or pivot table name
- Click "Create Relationship" β FK column auto-created, SVG line appears with cardinality markers
- Escape key cancels relationship mode at any point
7.5 Verification
| Check | Result |
|---|---|
| PHP syntax check | Both RelationshipManager.php and Canvas.php pass |
| Routes | All 3 routes still registered |
| Asset build | CSS 62.9KB (grew from 61.7KB β relationship mode classes picked up) |
| Cardinality markers | 4 SVG markers defined (one-start, one-end, many-start, many-end) |
| Event flow | Alpine dispatches β Livewire #[On] listener β Modal opens |
| FK auto-creation | Logic covers source, target, and pivot locations |
| Bezier paths | Cubic curves replace straight lines for smoother visuals |
| RelationshipManager embedded | In side panel under table editor, scoped to project |
Files Created/Modified in Step 7
| File | Action | Purpose |
|---|---|---|
packages/laraschema/src/Http/Livewire/RelationshipManager.php |
Created | Full relationship CRUD with FK auto-creation |
packages/laraschema/resources/views/livewire/relationship-manager.blade.php |
Created | Create modal, relationships list, delete confirmation |
packages/laraschema/src/Http/Livewire/Canvas.php |
Modified | Added event listeners, cardinality data, RelationshipType import |
packages/laraschema/resources/views/livewire/canvas.blade.php |
Replaced | Relationship mode, SVG markers, bezier paths, preview line, banner |
Step 8: Code Generation + Export
Date: 2026-02-12 Status: Complete
8.1 MigrationGenerator
File: packages/laraschema/src/Generators/MigrationGenerator.php
Pure PHP service class with no HTTP or Livewire dependency. Generates valid Laravel migration files from a project's schema.
Public API:
| Method | Returns | Purpose |
|---|---|---|
generateForProject(Project) |
array<string, string> |
Generates all migration files (filename β content) |
Protected methods (internal):
| Method | Purpose |
|---|---|
generateTableMigration(Table) |
Creates Schema::create() migration for a table |
generateColumns(Table) |
Builds all column definitions including id/timestamps/softDeletes |
generateColumnLine(Column) |
Produces a single $table->type('name') line |
generateColumnType(Column, ColumnType) |
Handles type-specific parameters (length, precision, enum values) |
generateColumnModifiers(Column) |
Chains modifiers: nullable, default, unique, index, unsigned, comment |
formatDefaultValue(Column) |
Formats defaults (null, bool, numeric, string) correctly |
generateForeignKeyMigration(Project) |
Creates a separate migration for FK constraints |
generateForeignKeyLine(...) |
Produces $table->foreign()->references()->on() calls |
generateDropForeignKeyLines(Project) |
Produces $table->dropForeign() for down() method |
generatePivotMigration(Relationship) |
Creates pivot table migration for belongsToMany |
Output structure:
1. {timestamp}_000001_create_{table1}_table.php β Table migrations (sorted by sort_order)
2. {timestamp}_000002_create_{table2}_table.php
3. {timestamp}_000003_create_{table3}_table.php
4. {timestamp}_000004_add_foreign_keys.php β FK constraints (single file, if any)
5. {timestamp}_000005_create_{pivot}_table.php β Pivot tables (one per belongsToMany)
Column type handling:
| Category | Examples | Parameters |
|---|---|---|
| Length types | string, char |
string('name', 200) |
| Precision types | decimal, double, float |
decimal('price', 8, 2) |
| Enum/Set | enum, set |
enum('status', ['draft', 'published']) |
| No-name types | rememberToken, morphs |
rememberToken() |
| Foreign types | foreignId, foreignUlid, foreignUuid |
foreignId('user_id') |
| All others | text, boolean, json, etc. |
type('name') |
Modifier chaining order: ->unsigned()->nullable()->default(val)->unique()->index()->comment(str)
Default value formatting:
'null'βnull- Boolean type +
'true'/'1'βtrue/false - Numeric types + numeric string β unquoted number
- Everything else β
'quoted string'
FK migration features:
- Uses
->cascadeOnDelete(),->restrictOnDelete(),->nullOnDelete()based onon_deletesetting down()method uses$table->dropForeign(['column_name'])- Pivot tables use
foreignId()->constrained()->cascadeOnDelete()with a unique composite key
8.2 SchemaExportService
File: packages/laraschema/src/Services/SchemaExportService.php
| Method | Returns | Purpose |
|---|---|---|
generateZip(Project) |
string |
Path to temporary ZIP file |
- Injects
MigrationGeneratorvia constructor - Creates ZIP at
storage/app/laraschema-exports/{slug}-migrations-{datetime}.zip - Files placed inside
database/migrations/folder within the ZIP - Creates export directory if it doesn't exist
8.3 CodePreview Livewire Component
File: packages/laraschema/src/Http/Livewire/CodePreview.php
| Property | Type | Purpose |
|---|---|---|
$project |
Project |
The project to preview |
$activeFile |
string |
Currently selected filename |
$files |
array |
Generated migration files (filename β content) |
| Method | Purpose |
|---|---|
mount(Project) |
Loads project, generates initial code |
generateCode() |
Regenerates all migration files |
selectFile(string) |
Switches to a different file tab |
8.4 CodePreview Blade View
File: packages/laraschema/resources/views/livewire/code-preview.blade.php
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Migration Preview [Refresh] [Export]β
βββββββββββββ¬βββββββββββββββββββββββββββββββββββββββ€
β File Tabs β Code View [Copy]β
β β β
β > users β 1 <?php β
β posts β 2 β
β tags β 3 use Illuminate\Database\... β
β fk β 4 use Illuminate\Database\... β
β pivot β 5 β
β β 6 return new class extends ... β
β β 7 { β
β β 8 public function up() β
β β ... β
βββββββββββββ΄βββββββββββββββββββββββββββββββββββββββ
Features:
- Header bar: "Migration Preview" title, Refresh button (regenerates code), Export ZIP link
- File tabs (left sidebar, w-56): Clickable list of migration files, active file highlighted with blue icon
- Code view (right panel): Dark background (gray-900), monospace font, line numbers, Copy button (uses
navigator.clipboard) - Empty state: Shown when no tables exist, with code icon and guidance text
- Copy feedback: "Copy" β "Copied!" for 2 seconds via Alpine.js
8.5 ExportController
File: packages/laraschema/src/Http/Controllers/ExportController.php
Replaced Step 2 placeholder with full implementation:
| Method | Returns | Purpose |
|---|---|---|
download(Project, SchemaExportService) |
BinaryFileResponse |
Generates ZIP and returns download |
- Uses Laravel's dependency injection to resolve
SchemaExportService - Download filename:
{project-slug}-migrations.zip ->deleteFileAfterSend(true)cleans up the temporary ZIP file
8.6 Canvas Integration
Canvas.php changes:
- Added
$showCodePreviewboolean property - Added
toggleCodePreview()andcloseCodePreview()methods - Code preview deselects any selected table when opened
Canvas Blade view changes:
- Added "Preview Code" button in toolbar (dark when active)
- Added code preview bottom panel (50vh height, absolute positioned)
- Panel respects side panel: adjusts
rightoffset when table editor is open
8.7 Verification
Tinker test with complex schema (users/posts/tags with relationships):
| Generated File | Content |
|---|---|
create_users_table.php |
id(), string('name'), string('email')->unique(), string('password'), text('bio')->nullable(), integer('age')->nullable()->default(0), boolean('is_admin')->default(false), rememberToken(), timestamps(), softDeletes() |
create_posts_table.php |
id(), unsignedBigInteger('user_id'), string('title', 200), string('slug')->unique(), longText('content'), enum('status', ['draft', 'published', 'archived'])->default('draft'), dateTime('published_at')->nullable(), timestamps() |
create_tags_table.php |
id(), string('name')->unique(), string('slug')->unique(), timestamps() |
add_foreign_keys.php |
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete() |
create_post_tag_table.php |
foreignId('post_id')->constrained('posts')->cascadeOnDelete(), foreignId('tag_id')->constrained('tags')->cascadeOnDelete(), unique(['post_id', 'tag_id']) |
| Check | Result |
|---|---|
| PHP syntax check | All 5 new/modified files pass |
| Routes | All 3 routes still registered |
| Asset build | CSS 63.6KB (grew from 62.9KB β code preview panel classes) |
| Tinker test | Valid Laravel migration output for all column types |
| Default values | Numeric 0, boolean false, string 'draft' β all correctly formatted |
| FK constraints | Separate migration with correct cascadeOnDelete() |
| Pivot table | Correct foreignId()->constrained() with unique composite key |
rememberToken() |
Generated without column name parameter |
enum() |
Generated with array of allowed values |
string() with length |
string('title', 200) β length parameter included |
Files Created/Modified in Step 8
| File | Action | Purpose |
|---|---|---|
packages/laraschema/src/Generators/MigrationGenerator.php |
Created | Core migration code generator |
packages/laraschema/src/Services/SchemaExportService.php |
Created | ZIP file creation for download |
packages/laraschema/src/Http/Livewire/CodePreview.php |
Created | Tabbed code viewer component |
packages/laraschema/resources/views/livewire/code-preview.blade.php |
Created | File tabs, code display, copy button |
packages/laraschema/src/Http/Controllers/ExportController.php |
Replaced | Full ZIP download implementation |
packages/laraschema/src/Http/Livewire/Canvas.php |
Modified | Added code preview toggle |
packages/laraschema/resources/views/livewire/canvas.blade.php |
Modified | Added Preview Code button + bottom panel |
Phase 1 Complete
All 8 steps of the MVP implementation plan have been completed:
| Step | Status | What was built |
|---|---|---|
| 1. Scaffolding | Complete | Laravel 12 app, Livewire 4, package monorepo |
| 2. Foundation | Complete | Config, routes, 5 migrations, 5 models, 2 enums, service provider |
| 3. Project CRUD | Complete | ProjectManager with card grid, create/rename/delete |
| 4-5. Canvas | Complete | SVG canvas with pan/zoom/drag, table nodes, Alpine.js integration |
| 6. Table Editor | Complete | Side panel with column CRUD, settings, reorder |
| 7. Relationships | Complete | Draw mode, FK auto-creation, SVG bezier lines with cardinality |
| 8. Code Gen | Complete | MigrationGenerator, CodePreview, ZIP export |
Total files in package: 30 (matching the plan's file manifest)
Final asset sizes:
- CSS: 63.6KB (gzip: 12.3KB)
- JS: 36.7KB (gzip: 14.8KB)
Phase 2: Complete Code Generation
Date: 2026-02-16 Status: Complete
Phase 2 Overview
Phase 2 expands LaraSchema from a migration-only generator to a complete Laravel code scaffolding tool. Users can now export production-ready Eloquent Models, Factories, and Seeders alongside migrations.
Goals achieved:
- β
Generate Eloquent models with relationships,
$fillable,casts(), traits - β Generate factories with intelligent Faker methods and state management
- β Generate seeders with dependency ordering (topological sort)
- β Category-based code preview UI (Migrations, Models, Factories, Seeders)
- β Complete codebase ZIP export with proper Laravel directory structure
Step 9: ColumnType Enum Enhancements
Date: 2026-02-16 Status: Complete
9.1 Added phpCastType() Method
File modified: packages/laraschema/src/Enums/ColumnType.php
Maps column types to Eloquent cast types for model generation:
public function phpCastType(): ?string
{
return match ($this) {
self::Boolean => 'boolean',
self::Integer, self::BigInteger => 'integer',
self::Decimal => 'decimal:2',
self::DateTime, self::Timestamp => 'datetime',
self::Date => 'date',
self::Json, self::Jsonb => 'array',
default => null,
};
}
Used by: ModelGenerator for generating casts() method
9.2 Added fakerMethod() Method
Maps column types to appropriate Faker method calls for factory generation:
public function fakerMethod(): string
{
return match ($this) {
self::String => 'fake()->text(50)',
self::Text => 'fake()->paragraph()',
self::Integer => 'fake()->randomNumber()',
self::Boolean => 'fake()->boolean()',
self::DateTime => 'fake()->dateTime()',
self::Uuid => 'fake()->uuid()',
default => 'fake()->word()',
};
}
Coverage: All 35+ column types mapped to realistic Faker calls
Step 10: ModelGenerator
Date: 2026-02-16 Status: Complete
10.1 ModelGenerator Implementation
File created: packages/laraschema/src/Generators/ModelGenerator.php
Pure PHP service following the established generator pattern. Generates Eloquent models with Laravel 12 conventions.
Public API:
| Method | Returns | Purpose |
|---|---|---|
generateForProject(Project) |
array<string, string> |
Generates all model files (filename β content) |
Protected methods:
| Method | Purpose |
|---|---|
generateModel(Table, Project) |
Creates complete model file with namespace, imports, class body |
generateFillable(Table) |
Builds $fillable array (excludes id, timestamps, deleted_at) |
generateCasts(Table) |
Generates casts() method using ColumnType::phpCastType() |
generateHidden(Table) |
Builds $hidden array for password, remember_token |
generateRelationships(Table, Project) |
Generates all relationship methods for table |
generateRelationshipMethod(Relationship, side, Project) |
Single relationship with correct return type |
generateBelongsToManyBody(Relationship, model) |
Special handling for belongsToMany with pivot table |
getRelationshipMethodName(type, tableName, side) |
Determines method name (camelCase singular/plural) |
getRelationshipImports(Table, Project) |
Collects needed relationship class imports |
generateTraits(Table) |
Returns SoftDeletes if applicable |
generateTimestampsConfig(Table) |
Returns public $timestamps = false if needed |
Output structure:
User.php β namespace App\Models; class User extends Model { ... }
Post.php β namespace App\Models; class Post extends Model { ... }
Tag.php β namespace App\Models; class Tag extends Model { ... }
Features:
- Uses
casts()method (Laravel 12 style) not$castsproperty - Handles decimal precision:
'price' => 'decimal:2' - Auto-hides sensitive fields: password, remember_token
- Generates typed relationship methods with proper imports
- BelongsToMany includes pivot table name and keys
- Relationship method naming: hasOne/belongsTo β singular, hasMany/belongsToMany β plural
10.2 Verification
Tinker test:
$project = Project::with(['tables.columns', 'relationships'])->first();
$generator = new ModelGenerator();
$models = $generator->generateForProject($project);
// Generated 5 models
count($models); // 5
// Syntax validation
foreach ($models as $filename => $content) {
token_get_all($content); // All pass
}
// Sample output check
$models['User.php']; // Contains proper fillable, casts, relationships
Generated model quality checks:
- β
All models use
declare(strict_types=1); - β Fillable arrays exclude id, timestamps, soft_deletes
- β Casts method uses correct types (boolean, integer, datetime, array)
- β Relationship methods have return type hints
- β SoftDeletes trait added when table uses soft deletes
- β Timestamps disabled when table doesn't use timestamps
Step 11: FactoryGenerator
Date: 2026-02-16 Status: Complete
11.1 FactoryGenerator Implementation
File created: packages/laraschema/src/Generators/FactoryGenerator.php
Generates factory files with intelligent Faker method selection and state management.
Public API:
| Method | Returns | Purpose |
|---|---|---|
generateForProject(Project) |
array<string, string> |
Generates all factory files (filename β content) |
Protected methods:
| Method | Purpose |
|---|---|
generateFactory(Table, Project) |
Creates complete factory file |
generateDefinitionArray(Table, Project) |
Builds definition() method array |
generateFakerForColumn(Column, Table, Project) |
Intelligent Faker method for single column |
getSpecialColumnValue(Column, Table) |
Heuristic detection for common column names |
getForeignKeyValue(Column, Table, Project) |
Detects FK and returns Model::factory() |
generateStateMethods(Table) |
Auto-generates unverified(), trashed(), status states |
Special column handling (heuristics):
| Column Name | Generated Value |
|---|---|
email |
fake()->unique()->safeEmail() |
password |
Hash::make('password') |
remember_token |
Str::random(10) |
slug |
Str::slug(fake()->words(3, true)) |
email_verified_at |
now() |
phone |
fake()->phoneNumber() |
url / website |
fake()->url() |
city |
fake()->city() |
country |
fake()->country() |
Foreign key handling:
- Detects FK from relationships:
'user_id' => User::factory() - Falls back to naming convention:
author_idβAuthor::factory()
State methods (auto-generated):
unverified()β ifemail_verified_atcolumn existstrashed()β ifdeleted_atcolumn exists (soft deletes)- Status-based β if
statusenum exists, generates state per value
11.2 Verification
Tinker test:
$generator = new FactoryGenerator();
$factories = $generator->generateForProject($project);
// Generated 5 factories
count($factories); // 5
// Syntax validation
foreach ($factories as $filename => $content) {
token_get_all($content); // All pass
}
// Foreign key check
$factories['PostFactory.php']; // Contains 'user_id' => User::factory()
Generated factory quality checks:
- β
All factories extend
Factorywith proper PHPDoc - β Faker methods match column types (text β paragraph, boolean β boolean)
- β Special columns handled (email unique, password hashed, slug generated)
- β
Foreign keys use
Model::factory()not random IDs - β State methods generated for email_verified_at, deleted_at, status enums
- β
Nullable columns wrapped in
fake()->optional(0.7)
Step 12: SeederGenerator
Date: 2026-02-16 Status: Complete
12.1 SeederGenerator Implementation
File created: packages/laraschema/src/Generators/SeederGenerator.php
Generates seeder files with dependency ordering using topological sort (Kahn's algorithm).
Public API:
| Method | Returns | Purpose |
|---|---|---|
generateForProject(Project) |
array<string, string> |
Generates all seeder files + DatabaseSeeder |
Protected methods:
| Method | Purpose |
|---|---|
generateSeeder(Table, Project) |
Individual table seeder with factory calls |
generatePivotSeeding(Table, Project) |
BelongsToMany attachment logic |
generateDatabaseSeeder(Project) |
Master seeder with ordered $this->call() statements |
buildDependencyOrder(Project) |
Topological sort via Kahn's algorithm |
Dependency ordering (Kahn's algorithm):
- Build dependency graph from relationships
- BelongsTo β source depends on target
- HasOne/HasMany β target depends on source
- Calculate in-degree for each table
- Queue tables with in-degree = 0
- Process queue, decrement dependencies
- Result:
users β posts β comments(respects FK order)
Pivot table seeding:
Post::all()->each(function (Post $model) {
$model->tags()->attach(
Tag::inRandomOrder()->take(rand(1, 3))->pluck('id')
);
});
Output structure:
UserSeeder.php β User::factory()->count(10)->create();
PostSeeder.php β Post::factory()->count(10)->create(); + pivot seeding
TagSeeder.php β Tag::factory()->count(10)->create();
DatabaseSeeder.php β $this->call([...]) in dependency order
12.2 Verification
Tinker test:
$generator = new SeederGenerator();
$seeders = $generator->generateForProject($project);
// Generated 6 seeders (5 tables + DatabaseSeeder)
count($seeders); // 6
// Syntax validation
foreach ($seeders as $filename => $content) {
token_get_all($content); // All pass
}
// Dependency order check
$seeders['DatabaseSeeder.php'];
// Contains: UserSeeder, PostSeeder, CommentSeeder (correct order)
Dependency ordering test:
// Test project: users β posts β comments (linear dependency)
$order = (new SeederGenerator())->buildDependencyOrder($project);
$order[0]->name; // 'users'
$order[1]->name; // 'posts'
$order[2]->name; // 'comments'
Generated seeder quality checks:
- β
Individual seeders call
Model::factory()->count(10)->create() - β
Pivot seeding uses
attach()with random associations - β DatabaseSeeder lists seeders in dependency order
- β Circular dependencies fall back to original table order
- β All seeders have proper namespace and use statements
Step 13: UI Integration - CodePreview Component
Date: 2026-02-16 Status: Complete
13.1 CodePreview Component Updates
File modified: packages/laraschema/src/Http/Livewire/CodePreview.php
Refactored from flat file list to category-based structure.
Before (Phase 1):
public array $files = []; // Flat array of migrations
public string $activeFile = '';
After (Phase 2):
public array $filesByCategory = [
'migrations' => [],
'models' => [],
'factories' => [],
'seeders' => [],
];
public string $activeCategory = 'migrations';
public string $activeFile = '';
New methods:
| Method | Purpose |
|---|---|
selectCategory(string) |
Switch active category, auto-select first file |
selectFirstFileInCategory() |
Helper to update activeFile on category change |
getActiveFilesProperty() |
Computed property for current category files |
Code generation:
public function generateCode(): void
{
$this->filesByCategory = [
'migrations' => (new MigrationGenerator())->generateForProject($this->project),
'models' => (new ModelGenerator())->generateForProject($this->project),
'factories' => (new FactoryGenerator())->generateForProject($this->project),
'seeders' => (new SeederGenerator())->generateForProject($this->project),
];
}
13.2 CodePreview Blade View Updates
File modified: packages/laraschema/resources/views/livewire/code-preview.blade.php
Added category navigation section with file counts and icons.
New UI sections:
-
Category Tabs (vertical button group):
- Migrations (database icon) + count badge
- Models (box icon) + count badge
- Factories (beaker icon) + count badge
- Seeders (palette icon) + count badge
- Active category highlighted with blue background
- Click calls
wire:click="selectCategory('...')"
-
File List (per category):
- Iterates
$this->activeFilesinstead of$files - Shows files only for active category
- File selection updates within category
- Iterates
-
Code View:
- Uses
$this->activeFiles[$activeFile]for content - Copy button uses same Alpine.js mechanism
- Uses
Visual polish:
- Active category:
bg-blue-50 text-blue-700 - Badge colors match active state
- Empty state: "No files to generate" (generic across categories)
13.3 Verification
| Check | Result |
|---|---|
| Category tabs render | 4 tabs visible with icons and counts |
| Category switching | File list updates to show category files |
| File counts | Match actual generated file counts |
| Active file selection | Persists within category, resets on switch |
| Copy functionality | Still works with new structure |
| Empty category | Shows appropriate message |
Step 14: SchemaExportService Integration
Date: 2026-02-16 Status: Complete
14.1 Service Updates
File modified: packages/laraschema/src/Services/SchemaExportService.php
Before (Phase 1):
public function __construct(
protected MigrationGenerator $generator
) {}
After (Phase 2):
public function __construct(
protected MigrationGenerator $migrationGenerator,
protected ModelGenerator $modelGenerator,
protected FactoryGenerator $factoryGenerator,
protected SeederGenerator $seederGenerator
) {}
Updated generateZip() method:
public function generateZip(Project $project): string
{
$migrations = $this->migrationGenerator->generateForProject($project);
$models = $this->modelGenerator->generateForProject($project);
$factories = $this->factoryGenerator->generateForProject($project);
$seeders = $this->seederGenerator->generateForProject($project);
// Add to ZIP with Laravel directory structure
foreach ($migrations as $filename => $content) {
$zip->addFromString("database/migrations/{$filename}", $content);
}
foreach ($models as $filename => $content) {
$zip->addFromString("app/Models/{$filename}", $content);
}
foreach ($factories as $filename => $content) {
$zip->addFromString("database/factories/{$filename}", $content);
}
foreach ($seeders as $filename => $content) {
$zip->addFromString("database/seeders/{$filename}", $content);
}
}
Filename change:
- Before:
{slug}-migrations-{date}.zip - After:
{slug}-{date}.zip(contains all file types)
14.2 Exported ZIP Structure
laraschema-export-2026-02-16.zip
βββ database/
β βββ migrations/
β β βββ 2026_02_16_000001_create_users_table.php
β β βββ 2026_02_16_000002_create_posts_table.php
β β βββ 2026_02_16_000003_add_foreign_keys.php
β β βββ 2026_02_16_000004_create_post_tag_table.php
β βββ factories/
β β βββ UserFactory.php
β β βββ PostFactory.php
β β βββ TagFactory.php
β βββ seeders/
β βββ DatabaseSeeder.php
β βββ UserSeeder.php
β βββ PostSeeder.php
β βββ TagSeeder.php
βββ app/
βββ Models/
βββ User.php
βββ Post.php
βββ Tag.php
14.3 Verification
Test export flow:
- Created test project with 3 tables (users, posts, tags)
- Added relationships (users hasMany posts, posts belongsToMany tags)
- Clicked "Export ZIP" button
- Downloaded ZIP file
- Extracted contents
Verification checks:
- β ZIP contains 4 directories (migrations, models, factories, seeders)
- β File count matches: 4 migrations, 3 models, 3 factories, 4 seeders
- β All files have valid PHP syntax
- β Models contain relationships
- β
Factories use
Model::factory()for foreign keys - β DatabaseSeeder lists seeders in dependency order
Manual Laravel installation test:
- Extracted ZIP into fresh Laravel 12 app
- Ran
php artisan migrateβ All tables created β - Ran
php artisan tinkerβUser::factory()->create()works β - Ran
php artisan db:seedβ Database populated with relationships β
Step 15: Code Formatting
Date: 2026-02-16 Status: Complete
15.1 Laravel Pint Formatting
Command:
vendor/bin/pint --dirty --format agent
Result: All new and modified files formatted to match Laravel conventions
Files formatted:
packages/laraschema/src/Enums/ColumnType.phppackages/laraschema/src/Generators/ModelGenerator.phppackages/laraschema/src/Generators/FactoryGenerator.phppackages/laraschema/src/Generators/SeederGenerator.phppackages/laraschema/src/Http/Livewire/CodePreview.phppackages/laraschema/src/Services/SchemaExportService.php
Phase 2 Complete
Summary:
| Component | Status | Files Created/Modified |
|---|---|---|
| ColumnType enum enhancements | Complete | 1 modified (+2 methods) |
| ModelGenerator | Complete | 1 created |
| FactoryGenerator | Complete | 1 created |
| SeederGenerator | Complete | 1 created |
| CodePreview component | Complete | 2 modified (PHP + Blade) |
| SchemaExportService | Complete | 1 modified |
| Total | Complete | 3 new files, 4 modified files |
Generated code quality:
- β All generators produce valid PHP syntax
- β Follows Laravel 12 conventions
- β
Models use
casts()method (not$castsproperty) - β Factories use realistic Faker methods
- β Seeders respect dependency ordering
- β Complete codebase ready for immediate use
Key achievements:
- Generator Pattern β Established reusable, testable pattern for code generation
- Intelligent Code β Heuristic detection for common columns (email, password, slug)
- Dependency Ordering β Topological sort ensures seeders run in correct order
- Complete Codebase β Users now get migrations + models + factories + seeders
- Category UI β Organized code preview improves UX
Phase 2 deliverables ready for production use.
Documentation Updates
Date: 2026-02-16 Status: Complete
Documentation Files Updated
| File | Changes |
|---|---|
docs/07-PHASE2-IMPLEMENTATION.md |
Created - Comprehensive Phase 2 implementation guide |
docs/02-ARCHITECTURE.md |
Updated - Added Code Generation Architecture section |
docs/05-ROADMAP.md |
Updated - Marked Phase 2 code generation as complete |
README.md |
Updated - Added Phase 2 features, updated status section |
docs/BUILD-LOG.md |
Updated - Added Phase 2 build steps (this file) |
Documentation completeness:
- β Architecture patterns documented
- β Implementation details with code examples
- β Verification procedures recorded
- β Roadmap updated to reflect progress
- β Team can understand and extend generators
Phase 2.5: Full Project Export
Date: 2026-02-17 Status: Complete
Phase 2.5 Overview
Phase 2.5 transforms the ZIP export from a collection of code files into a complete, ready-to-run Laravel + Filament application. The user experience goes from 6+ terminal commands to two commands + one browser click.
Goals achieved:
- β
LaravelProjectGeneratorservice β orchestrates full project assembly - β Base Laravel 12 + Filament 5 template
- β
Pre-generated
.envwith uniqueAPP_KEY - β Browser-based one-click setup wizard (Alpine.js, 4 states)
- β Idempotent setup steps with retry support
- β
Auto-seeded admin user (
admin@example.com/password) - β Cleaned up orphaned code (shell scripts, dead methods)
Step 16: LaravelProjectGenerator Service
Date: 2026-02-17 Status: Complete
16.1 Service Implementation
File created: packages/laraschema/src/Services/LaravelProjectGenerator.php
Orchestrates the full project generation pipeline:
| Step | Method | Purpose |
|---|---|---|
| 1 | copyBaseTemplate() |
Copies base Laravel 12 template from packages/laraschema/templates/laravel-12/base/ |
| 2 | injectGeneratedCode() |
Runs all 4 generators and writes output files |
| 3 | configureProject() |
Updates composer.json, creates .env, registers Filament, adds setup routes |
| 4 | generateReadme() |
Writes project README from stub |
| 5 | createZip() |
Packages temp directory into ZIP under storage/laraschema-exports/ |
Key decisions:
try/finallyaround all steps guarantees temp directory cleanup even on exceptionFile::ensureDirectoryExists()called before each write batch (no silent failures)DatabaseSeeder.phpwritten separately viawriteDatabaseSeeder()to inject admin user
Constructor:
public function __construct(
protected MigrationGenerator $migrationGenerator,
protected ModelGenerator $modelGenerator,
protected FactoryGenerator $factoryGenerator,
protected SeederGenerator $seederGenerator
) {}
16.2 Admin User Injection
writeDatabaseSeeder() checks whether a users table exists in the schema. If found, buildAdminUserAttributes() inspects actual columns and injects only the attributes that exist:
// If users table has email, name, password, email_verified_at:
\App\Models\User::factory()->create([
'email' => 'admin@example.com',
'name' => 'Admin User',
'password' => \Illuminate\Support\Facades\Hash::make('password'),
'email_verified_at' => now(),
]);
16.3 Files Created/Modified
| File | Action | Purpose |
|---|---|---|
packages/laraschema/src/Services/LaravelProjectGenerator.php |
Created | Full project assembly service |
Step 17: Base Template + Stubs
Date: 2026-02-17 Status: Complete
17.1 Base Laravel Template
Location: packages/laraschema/templates/laravel-12/base/
A clean Laravel 12 installation managed via:
php artisan laraschema:setup-template
This command clones a fresh Laravel 12 app into the template directory and removes files that the generator will inject (migrations, models, factories, seeders, default welcome view).
What the base template includes:
- Full Laravel 12 directory structure
composer.json(pre-configured β generator adds Filament dependency)bootstrap/,config/,routes/web.php,public/- Default Laravel migrations (users, password_resets, etc.)
What the generator injects on top:
- Schema-specific migrations, models, factories, seeders
app/Providers/Filament/AdminPanelProvider.phpapp/Http/Controllers/SetupController.php- Updated
routes/web.phpwith setup routes - Generated
welcome.blade.phpwith setup wizard - Pre-generated
.envwith uniqueAPP_KEY - Project
README.md
17.2 Stub Files
| Stub | Purpose |
|---|---|
AdminPanelProvider.php.stub |
Filament panel provider with auto-discovery |
.env.example.stub |
Environment template with SQLite + Filament config |
SetupController.php.stub |
4-step setup controller (migrate, seed, assets, resources) |
welcome.blade.php.stub |
Alpine.js setup wizard (complete rewrite) |
README.md.stub |
Project README with 2-command quick start |
17.3 Files Created
| File | Action | Purpose |
|---|---|---|
packages/laraschema/templates/laravel-12/stubs/AdminPanelProvider.php.stub |
Created | Filament admin panel provider |
packages/laraschema/templates/laravel-12/stubs/.env.example.stub |
Created | Environment template |
packages/laraschema/templates/laravel-12/stubs/SetupController.php.stub |
Created | 4-step setup controller |
packages/laraschema/templates/laravel-12/stubs/welcome.blade.php.stub |
Rewritten | Alpine.js 4-state setup wizard |
packages/laraschema/templates/laravel-12/stubs/README.md.stub |
Updated | 2-command quick start |
Step 18: Browser-Based Setup Wizard
Date: 2026-02-17 Status: Complete
18.1 Setup Wizard Architecture
The generated project's welcome page (/) is the setup wizard. It detects setup state server-side and initializes Alpine.js directly in the correct state β no flash or loading spinner.
States:
| State | Condition | UI |
|---|---|---|
idle |
Setup not started | "Run Setup" button + step preview list |
running |
Steps executing | Progress bar + per-step status icons |
complete |
All 4 steps succeeded | Credentials + "Open Admin Panel" button |
error |
Any step failed | Error message + "Try Again" button |
State detection:
@php
$isSetupComplete = File::exists(storage_path('app/.setup_complete'));
@endphp
<div x-data="setupWizard({{ $isSetupComplete ? 'true' : 'false' }})">
18.2 SetupController Steps
| Method | Route | Action |
|---|---|---|
stepMigrate() |
POST /setup/migrate |
php artisan migrate --force |
stepSeed() |
POST /setup/seed |
php artisan db:seed --force (skips if users exist) |
stepAssets() |
POST /setup/assets |
php artisan filament:assets |
stepResources() |
POST /setup/resources |
make:filament-resource --generate --force for each model; writes .setup_complete marker |
Idempotency: Each step is safe to retry. The seed step checks Schema::hasTable('users') && User::exists() before seeding.
18.3 Files Created/Modified
| File | Action | Purpose |
|---|---|---|
packages/laraschema/templates/laravel-12/stubs/SetupController.php.stub |
Created | 4-step JSON API controller |
packages/laraschema/templates/laravel-12/stubs/welcome.blade.php.stub |
Rewritten | Full 4-state Alpine.js setup wizard |
packages/laraschema/templates/laravel-12/stubs/setup.blade.php.stub |
Deleted | Orphaned β replaced by welcome page wizard |
Step 19: Pre-Generated .env
Date: 2026-02-17 Status: Complete
19.1 Implementation
LaravelProjectGenerator::createEnvFile() copies .env.example and injects a unique APP_KEY:
protected function createEnvFile(string $projectDir): void
{
$envExample = File::get("{$projectDir}/.env.example");
$key = 'base64:' . base64_encode(random_bytes(32));
$env = str_replace('APP_KEY=', "APP_KEY={$key}", $envExample);
File::put("{$projectDir}/.env", $env);
}
Benefit: Eliminates cp .env.example .env && php artisan key:generate from the user workflow.
Security: Each generated project gets a unique key via random_bytes(32).
Step 20: Cleanup + Robustness
Date: 2026-02-17 Status: Complete
20.1 Removed Orphaned Code
| Item | Reason |
|---|---|
setup-filament.sh stub |
Replaced by browser wizard |
setup-filament.bat stub |
Replaced by browser wizard |
setup.blade.php.stub |
Replaced by inline wizard on welcome page |
copySetupView() method |
Referenced deleted stub |
createFilamentSetupScript() method (~70 lines) |
Scripts removed |
20.2 Robustness Improvements
| Issue | Fix |
|---|---|
| Temp dir orphaned on exception | Wrapped generateProject() in try/finally |
| Silent write failures | Added File::ensureDirectoryExists() before each batch |
| Double generator call | writeDatabaseSeeder() now accepts already-generated seeders, no second call |
| JSON encode failure | Added error check + exception in updateComposerJson() |
| Comment numbering gap | Renumbered steps in configureProject() after removing old step |
20.3 Files Modified
| File | Changes |
|---|---|
packages/laraschema/src/Services/LaravelProjectGenerator.php |
Removed dead methods, added try/finally, fixed double generator call, added ensureDirectoryExists |
Phase 2.5 Complete
Summary:
| Component | Status | Notes |
|---|---|---|
LaravelProjectGenerator service |
Complete | Full project assembly with try/finally cleanup |
| Base Laravel 12 template | Complete | Via laraschema:setup-template artisan command |
Pre-generated .env with APP_KEY |
Complete | random_bytes(32) unique per project |
AdminPanelProvider stub |
Complete | Auto-registers, auto-discovers resources |
SetupController stub |
Complete | 4 idempotent JSON endpoints |
| Alpine.js setup wizard | Complete | 4-state UI (idle/running/complete/error) |
| Admin user seeding | Complete | Column-aware injection into DatabaseSeeder |
| Cleanup | Complete | Shell scripts, dead methods, orphaned stubs removed |
User workflow after Phase 2.5:
composer install # install dependencies
php artisan serve # start server
# Open http://localhost:8000
# Click "Run Setup"
# Click "Open Admin Panel"
Generated project quality:
- β
Runs immediately after
composer install - β
No
cp .env.example .envorkey:generateneeded - β Browser wizard handles migrate, seed, assets, resource generation
- β
Admin panel ready at
/adminafter setup completes - β Setup is idempotent β safe to retry if a step fails
Documentation Updates (Phase 2.5)
Date: 2026-02-17 Status: Complete
Documentation Files Updated
| File | Changes |
|---|---|
docs/08-PHASE2.5-IMPLEMENTATION.md |
Created/Rewritten β comprehensive Phase 2.5 guide |
docs/01-PROJECT-OVERVIEW.md |
Updated Solution section to mention full project generation |
docs/02-ARCHITECTURE.md |
Added LaravelProjectGenerator to Services, added Phase 2.5 section |
docs/05-ROADMAP.md |
Added Phase 2.5 to phase overview table + dedicated section |
docs/07-PHASE2-IMPLEMENTATION.md |
Added forward-link to Phase 2.5 doc |
docs/BUILD-LOG.md |
Added Phase 2.5 build steps (this file) |
MCP Server Integration
Date: 2026-06-24
Status: Complete
Plan: docs/14-MCP-SERVER-PLAN.md
Exposed LaraSchema as an authenticated MCP server so external AI clients (Claude Desktop, Claude Code, Cursor) can read and design a user's schema projects.
Build steps
| Step | Status | Notes |
|---|---|---|
Publish routes/ai.php, scaffold LaraSchemaServer |
Complete | Mcp::web('/mcp/laraschema', β¦) |
| Auth + rate limiting | Complete | auth:sanctum + throttle:mcp (60/min/user) |
| Install Laravel Sanctum | Complete | Was not previously installed; added guard, HasApiTokens, personal_access_tokens migration |
Extract SchemaMutator + SchemaSerializer |
Complete | Single source of truth; TableEditor + Canvas refactored to use them |
| Read tools | Complete | list_projects, get_schema |
| Bulk tool | Complete | apply_schema wraps AiSchemaChat::apply() |
| Fine-grained CRUD tools | Complete | create_table, add_column, delete_column, create_relationship, delete_table |
| Token management UI | Complete | McpTokens Livewire component at /mcp-tokens (mint/list/revoke) |
| Tests | Complete | 26 MCP feature tests; full root suite 117 passed |
New dependency
laravel/sanctum(approved) β bearer-token auth for MCP clients.
Tools exposed
All tools authorize the project against the authenticated token's owner.
list_projectsβ the user's projects (id, name, table count, public flag)get_schemaβ a project's tables/columns/relationships as JSONapply_schemaβ non-destructive bulk merge of a full schemacreate_table,add_column,delete_column,create_relationship,delete_tableβ surgical edits
Manual verification
php artisan mcp:inspector mcp/laraschema # add an Authorization: Bearer <token> header
MCP Server β OAuth 2.1 via Passport
Date: 2026-06-25 Status: Complete
Added OAuth 2.1 so MCP clients that only support OAuth β notably claude.ai's
"Add custom connector" form β can authenticate via the browser consent flow.
Migrated the API auth layer from Sanctum to Laravel Passport so a single
auth:api guard validates both OAuth tokens and personal access tokens (the
existing bearer-token / mcp-remote flow keeps working).
Changes
| Area | Change |
|---|---|
| Dependency | Installed laravel/passport (install:api --passport) |
| Guard | config/auth.php: replaced sanctum guard with api (passport driver) |
| User | Swapped Sanctum\HasApiTokens β Passport\HasApiTokens |
| Routes | routes/ai.php: Mcp::oauthRoutes() + auth:api on the MCP web route |
| Consent UI | Published resources/views/mcp/authorize.blade.php; Passport::authorizationView(...) + Passport::tokensCan(['mcp:use' => ...]) in AppServiceProvider |
| Token UI | McpTokens now mints Passport personal access tokens; revoke flags revoked; tokens shown without last_used_at |
| Connect page | Added a "Claude.ai (custom connector)" section β paste the URL, leave OAuth fields blank (dynamic client registration), approve in browser |
| Tests | Token tests updated for Passport; added HttpAuthTest (401 on unauthenticated MCP request + .well-known discovery docs) |
How clients authenticate now
- claude.ai custom connector β paste
https://laraschema.com/mcp/laraschema, leave OAuth Client ID/Secret blank. The server advertises discovery via.well-known/oauth-protected-resource+.well-known/oauth-authorization-serverand self-registers the client viaPOST /oauth/register. User approves in browser. - Claude Desktop / Claude Code β still work with a personal access token from
/mcp-tokensas a bearer header (validated by the sameauth:apiguard).
Production deployment steps (run on the server, e.g. laraschema.com)
composer install --no-dev
php artisan migrate # creates oauth_* tables
php artisan passport:keys # generate encryption keys (DO NOT commit)
php artisan passport:client --personal # personal access client (for token minting)
- Ensure
APP_URL=https://laraschema.comso OAuth/MCP URLs are correct. storage/oauth-private.key/oauth-public.keyare gitignored β generate them on each environment (or setPASSPORT_PRIVATE_KEY/PASSPORT_PUBLIC_KEYenv vars).- Sanctum is left installed but unused (guard + trait removed); its
personal_access_tokenstable is now dead weight and can be dropped later.
Amended: that cleanup was completed the same day, not later β the package was removed from
composer.jsonand the table dropped by2026_06_25_120000_drop_personal_access_tokens_table.php. Sanctum is gone; the only remaininglaravel/sanctumstring incomposer.lockbelongs to another package's dev-requires.docs/14-MCP-SERVER-PLAN.mdhas been marked superseded where it still described the Sanctum plan.
MCP Server β Full Relationship Type Coverage
Date: 2026-08-18
Status: Complete
Plan: docs/14-MCP-SERVER-PLAN.md
MCP could only create 4 of the 8 relationship types the canvas offers, so an external client could not build the polymorphic schemas the editor can β or reproduce two of our own seeded templates.
The two bugs behind the gap
create_relationship hard-coded ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany']
in its input schema, and SchemaMutator::createRelationship() rejected anything
where isMorphic() was true. That much was a clean, tested limitation.
Worse was apply_schema. It bypassed SchemaMutator entirely and went through
AiSchemaChat::apply(), whose relationship pass only special-cased
belongsToMany. A morphMany therefore passed RelationshipType::tryFrom(),
skipped every column-creating branch, and persisted with no {morph}_type /
{morph}_id columns and no morphic pivot table β while ModelGenerator happily
emitted morphTo() against columns that never existed. The same line silently
coerced an unrecognized type string into hasMany. And SchemaDesigner's prompt
advertised morphOne / morphMany as valid, so the in-app AI chat was actively
steered into producing the broken shape.
Changes
| Area | Change |
|---|---|
SchemaMutator |
Supports all 8 mvpTypes(); guard now rejects only the *Through types. New ensureMorphColumns() and public resolvePivotNaming() (pivot/morph naming, no DB writes, so callers can duplicate-check first). Records target_column_id for hasOne/hasMany as the editor does |
CreateRelationshipTool |
type enum built from SchemaMutator::supportedTypeValues() so it cannot drift from the enum again |
AiSchemaChat::apply() |
Relationship pass delegates to SchemaMutator, catching SchemaMutationException to keep the merge non-destructive. Dead ensurePivotForeignKeyColumn() removed |
SchemaDesigner prompt |
Lists all 8 types; tells the model not to hand-declare morph columns |
ApplySchemaTool |
Description documents the accepted types and the implied-columns rule |
Adjacent bugs fixed
- Self-referential
belongsToManyin the mutator derived both pivot keys from the same table name, soensureForeignKeyColumncreatedpost_idonce and the pivot ended up with a single column. Nowparent_id/child_id, matching the editor. - The AI prompt asked for
setNull/noAction, butMigrationGeneratormatches on'set null'/'no action'and nothing normalized between them, so those referential actions silently generated no clause at all.
Behavior change
apply_schema now creates the FK column a belongsTo / hasMany implies (and
flags an existing one is_foreign). It previously created nothing and relied on
the AI declaring user_id itself as a plain column, so AI- and MCP-authored
schemas rendered and generated differently from hand-drawn ones.
Divergences left in place deliberately
- Pivot table flags differ: the canvas editor creates pivots with
use_id = true, use_timestamps = true; the mutator usesfalse, false. Changing either shifts generated migrations. - The editor blocks a second relationship reusing a
pivot_table_name; the mutator allows it. That is what lets MCP build the canonical sharedtaggablesshape (the polymorphic-tags template's) which the editor itself cannot draw, since it derives per-source names (postables,videoables). - There is no
delete_relationshipMCP tool, so the morph-column cleanup inRelationshipManager::deleteRelationship()has no MCP counterpart. hasOneThrough/hasManyThroughremain unsupported everywhere.
Tests
packages/laraschema/tests/Feature/Services/SchemaMutatorRelationshipTest.php
(18 tests) pins the mutator to the same shapes RelationshipManagerTest pins the
editor to, including two end-to-end tests asserting a mutator-built polymorphic
schema generates real morphMany() / morphTo() and a migration containing the
morph columns. Plus 4 new apply_schema tests and 3 new create_relationship
tests.
CrudToolsTest's "rejects an unsupported relationship type" was asserting the
gap itself (morphMany errors) β it now uses hasManyThrough, which is still
genuinely unsupported.
Both suites green: 686 package, 608 root.
MCP Server β Column Vocabulary Parity + Enum Correctness
Date: 2026-08-18
Status: Complete
Plan: docs/14-MCP-SERVER-PLAN.md
Follow-up to the relationship-coverage work above. Auditing the rest of the MCP surface for the same defect class turned up four issues, three of them identical in shape to the ones just fixed.
1. add_column silently coerced an invalid type
AddColumnTool declared type as a bare string with no enum, and
SchemaMutator::addColumn() did ColumnType::tryFrom(...) ?? ColumnType::String.
Probed directly: type: "varchar" became a string column, no error. This was
the same silent coercion as the relationship pass's ?? HasMany, and a test
(CrudToolsTest > falls back to string for an unknown column type) had pinned it
as intended behavior.
addColumn() now throws SchemaMutationException listing the valid types, and
the tool's type enum is generated from a new ColumnType::values().
2. Enum columns generated a migration that cannot run
Nothing required values for enum / set. MigrationGenerator guards on
$column->enum_values and fell through to the plain branch, emitting:
$table->enum('status');
That is not valid Laravel β enum() needs its allowed-values array, so the
generated migration throws on php artisan migrate.
Reachable two ways. Via MCP/AI, because enum_values could not be set at all
(issue 3) while enum was advertised as a valid type in both the tool
description and the SchemaDesigner prompt. And via the canvas β not through the
add-column form, which already validates newColumnEnumValues as required for
those types, but through TableEditor::updateColumn(), which let an inline edit
switch a column's type to enum or clear the values off an existing one with
no validation at all.
Fixed at all three layers: addColumn() rejects a valueless enum/set,
updateColumn() rejects the switch and the clear, and MigrationGenerator
degrades a legacy valueless enum to string('x') so already-saved projects still
generate something that migrates.
3. MCP could not set 7 of a column's attributes
SchemaMutator::addColumn() already accepted length, precision, scale,
default_value, is_unsigned, enum_values and comment β only the callers
never passed them. AddColumnTool now exposes all seven, and AiSchemaChat had
three duplicated inline column-creates (pass 1, pass 2, mergeTableChanges),
each copying five flags and each with its own ?? ColumnType::String fallback;
all three now route through one private createColumns() helper that delegates to
the mutator and skips a rejected column rather than aborting the merge.
4. get_schema was lossy in both directions
SchemaSerializer emitted only name/type/nullable/is_unique/is_index, so a
decimal read back without its precision and an enum without its values β and
apply_schema could not restore them. It also kept morph columns, because
the is_foreign filter misses them by design, contradicting the contract's own
rule that implied columns are never declared.
It now carries the seven attributes (each key present only when set, so a plain string column stays as terse as before) and derives the implied morph pairs from the project's relationships in order to filter them alongside foreign keys.
Tests
SchemaSerializerColumnsTest(6) β attribute round-trip, terse output for a plain column, and FK/morph-pair omission on both a polymorphic target and a morphic pivot.SchemaMutatorRelationshipTest+7 β type rejection,enum/setrequiring values,enum_valuesaccepted as a list or comma-separated string, and the generator's legacy-valueless-enum fallback.TableEditorTest+3 β the inline-edit guard in both directions, and that setting values first then switching type is allowed.CrudToolsTest+4,ApplySchemaToolTest+3.
CrudToolsTest > falls back to string for an unknown column type was replaced by
rejects an unknown column type instead of falling back to string β it was
asserting the defect.
Doc corrections
docs/14 claimed add_column "validates type against ColumnType" (Β§4) and
"rejects an invalid type with a clear message" (Β§7). Neither was true when
written; both are now.
Both suites green: 702 package, 615 root.
MCP Server β Edit and Delete Tools
Date: 2026-08-18
Status: Complete
Plan: docs/14-MCP-SERVER-PLAN.md
The last gap against the canvas editor. MCP could create and delete tables and columns, but not edit them, and could not delete a relationship at all β so the morph-column cleanup the editor does had no MCP counterpart.
New tools
| Tool | Behavior |
|---|---|
update_table |
Renames a table and/or changes use_id / use_timestamps / use_soft_deletes / color. Only what is passed changes. |
update_column |
Changes any column attribute, with new_name to rename. Omitted attributes keep their value; passing null clears an optional one. |
delete_relationship |
Removes a relationship and the columns it implied, identified by type plus its two tables. |
All three go through SchemaMutator, which gained renameTable(),
updateTableOptions(), updateColumn() and deleteRelationship().
Guards worth noting
Pivot tables cannot be renamed. A pivot's name is duplicated onto every
relationship that uses it as pivot_table_name, which MigrationGenerator and
ModelGenerator both read; renaming the table without rewriting those emits
migrations for a table that does not exist. The canvas disables the name field
for pivots (and for system tables) in the Blade template only, which is not a
check.
Corrected: this entry originally claimed putting the rule in the mutator closed the hole for the web path as well. It did not β
updateTableName()used the mutator only fornormalizeName()andtableNameRules(), and wrote the column itself, so only MCP was guarded.updateTableName()now delegates torenameTable()and surfaces its refusals on thetableNameerror bag, with a test asserting a pivot rename is rejected.
Deleting a relationship never takes something another one needs. Three cases:
a foreign key shared with the mirrored relationship on the other side of a
one-to-many; a morph name shared by a second polymorphic parent on the same child;
and a pivot table shared by two polymorphic many-to-many sides. The editor's
version of this logic destroys the pivot unconditionally β safe there only because
it blocks reusing a pivot name in the first place. Through SchemaMutator a shared
pivot is reachable (it is how the canonical taggables shape is built), so the
delete checks for remaining references first.
enum guard on edits. updateColumn() evaluates the enum rule against the
resulting state, so switching a column's type to enum without values in the
same call fails, as does clearing the values off one that is still an enum.
Server instructions were stale
LaraSchemaServer::$instructions β the prompt every MCP client reads β still said
"Unknown types fall back to string" and listed only four relationship types. Both
were wrong before this session's earlier changes and would have actively steered a
model into the broken shapes. Rewritten to cover all eight relationship types, the
full column vocabulary, the enum requirement, the implied-columns rule, and the
both-sides convention for one-to-many and many-to-many.
Tests
tests/Feature/Mcp/MutationToolsTest.php (20) covers all three tools including
the rename normalization, the system- and pivot-table refusals, selective updates,
null-clearing, the enum switch in both directions, and every delete-cleanup case β
FK, pivot, morph pair, second-parent morph pair retained, and shared pivot kept
until the last relationship goes. ToolAnnotationsTest's dataset covers the new
tools' titles and safety hints. SchemaMutatorRelationshipTest +5 for the
mirrored-FK invariant, the same-source morph-name invariant, and updateColumn
edge cases (comma-separated enum string, a scale of zero, duplicate rename).
Doc and UI lists updated: docs/14 Β§4, docs/15's annotation table, and the
tool list on the /mcp-tokens page.
Both suites green: 707 package, 638 root.
MCP Server β Project Entry Points (create_project, import_sql)
Date: 2026-08-18
Status: Complete
Plan: docs/14-MCP-SERVER-PLAN.md
Until now the MCP server could edit schemas but not start one. list_projects
returned what already existed, and every mutation tool required a project_id, so
a user driving LaraSchema from Claude Desktop had to open the web app, create a
project by hand, and come back. That undercut the whole point of the connector.
create_project
Creates an empty project owned by the caller and returns its id.
The important part is that it does not just insert a row.
ProjectManager::createProject() also builds the protected users table β five
authentication columns, is_system so it cannot be renamed or deleted β because
the generated Filament panel logs in against it, and a project without it produces
an app nobody can sign into. A tool that skipped that would produce projects that
export broken.
So project creation moved into SchemaMutator::createProject() plus a public
createDefaultUserTable(), and ProjectManager was refactored onto them,
deleting its private copy (~2.2 KB). Ownership stays with the caller: the Livewire
component passes a user_id or a guest token, the MCP tool always passes the
authenticated user_id. The guest project limit is a web concern and does not
apply.
import_sql
Turns a SQL dump into a new project, reusing the exact services behind the web
wizard β SqlSchemaInspector::inspect() and SchemaImporter::import() β so an
imported project is indistinguishable from one imported through the UI.
preview: truereports the tables, their column and foreign-key counts, and which are pivots, without creating anything. Lets the model show the user what it found and ask before committing.tables: [...]imports a subset, validated against the dump's actual tables so a typo is answered with the real list rather than a silent partial import.- Capped at 5 MB, matching the web wizard's
max:5120upload rule. - The response carries
inferred_relationship_countandhas_foreign_keys, so a dump with no declared FKs β where relationships were guessed from*_idnaming β can be flagged to the user as needing a check.
Always creates a NEW project; it never merges into an existing one. The tool
description says so, and points at apply_schema for merging.
Server instructions
Step 1 of the designing flow now offers all three entry points β list_projects
for existing work, create_project from scratch, import_sql from a dump β and a
convention notes the protected users table so the model builds on it instead of
adding a second one.
Tests
tests/Feature/Mcp/ProjectToolsTest.php (12): ownership and the exact
authentication column set for create_project, canvas settings present so the
editor can open the result, and that get_schema can immediately read a
just-created project; for import_sql, columns and foreign keys imported,
INSERT statements ignored, preview creating nothing, subset selection, the
unknown-table error naming what the dump does contain, no-CREATE-TABLE input, the
name requirement, and that an imported project is editable by the schema tools.
The 212 existing Livewire tests cover the ProjectManager refactor unchanged.
Both suites green: 707 package, 663 root.
AI Assistant β Explicit Removals and Renames
Date: 2026-08-18 Status: Complete
apply() could only add. A user asking the assistant to "drop the audit table" or
"rename posts to articles" got a confident reply and no change β or worse, a
duplicate. Probed before starting:
after first apply: posts, comments
after "rename postsβarticles, drop comments": posts, comments, articles
articles columns after dropping title: title
Why removals are explicit rather than diffed
The obvious design β treat the returned schema as desired state and delete
anything missing β is unsafe here, and the reason is in the prompt builder:
SchemaDesigner summarises the injected schema to table and column names once
it exceeds 40 KB, then instructs "never omit existing tables". On a large schema
the model is not shown enough to reproduce it, so inferring deletion from absence
would delete tables it was never given. Removals and renames are therefore two
explicit blocks:
{
"renames": { "tables": [{"from": "posts", "to": "articles"}],
"columns": [{"table": "articles", "from": "body", "to": "content"}] },
"removals": { "tables": ["audit_logs"],
"columns": [{"table": "articles", "name": "legacy_flag"}],
"relationships": [{"type": "hasMany", "source_table": "users", "target_table": "articles"}] }
}
Renames run first, so a table listed under its new name merges into the
existing row instead of creating a second one. Removals run last. Both
delegate to SchemaMutator, inheriting its guards β system and pivot tables
cannot be renamed, required columns cannot be dropped β and an instruction that
cannot be carried out is collected in a skipped list rather than aborting the
apply.
Undo had to be rebuilt first
buildCanvasSnapshot() stores only table_ids, column_ids and
relationship_ids, and undoApply() works by deleting whatever is not in those
lists. That undoes additions perfectly and cannot undo a deletion at all β an id
list has nothing to restore from. Shipping a destructive apply on top of it would
have meant unrecoverable data loss behind an Undo button.
So apply() now returns a report, and SchemaMutator gained
describeTable() / describeColumn() / describeRelationship() β
capturing the canvas geometry SchemaSerializer omits β plus
restoreRemoved(), which rebuilds tables first, then columns, then relationships
through createRelationship() so their foreign-key and morph columns come back
too. applySchema() merges that report into the undo snapshot; undoApply()
reverses renames by name (a renamed row keeps its id, so the id-based steps leave
it renamed) and then restores the removals.
Two cascades are captured that nothing announced before: deleteTable() removes
the table's relationships, and deleteColumn() removes relationships that used
the column. Both are recorded so undo restores them.
SchemaChatService::apply() changed from void to returning the report; the
contract, the adapter, Canvas and ApplySchemaTool were updated together.
The preview shows deletions
Canvas::schemaDiff() was additive-only β newTables, modifiedTables,
unchangedTables, newRelationships. It now also returns renames and
removals, and computes them even when no tables are proposed, since "drop the
audit table" is a complete instruction on its own and the destructive half of the
preview must never be the part that gets skipped. The blade renders removals in
red with a warning icon, a per-table column count, and a note that it can be
undone.
A bug the tests caught
ApplySchemaTool validated schema.tables and schema.relationships but not the
new blocks. Laravel's nested validation prunes keys it has no rules for, so
renames and removals were silently stripped between the request and apply()
β a removals-only call reached a schema key that no longer existed. Both are now
declared, with a regression test for the mixed tables-plus-removals case.
Tests
tests/Feature/Mcp/ApplySchemaRemovalsTest.php(18) β renames without duplication, column renames resolved against either table name, every removal kind, the users-table refusal, that omission still never deletes, removal-only schemas, restore fidelity (length, unique, enum values), restore of cascaded relationships, idempotent restore, and the MCP round trip.tests/Feature/Ai/CanvasUndoRemovalsTest.php(7) β undo brings back a removed table with its columns and relationships, a removed column, reverses table and column renames, handles a mixed add-and-remove apply, and the preview lists both renames and removals.SchemaDesignerInstructionsTestextended to pin the new prompt contract.
Both suites green: 707 package, 694 root.