πŸ“ Build Log

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/vite plugin)
  • 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 to src/
  • Laravel Auto-discovery: Registers LaraSchema\LaraSchemaServiceProvider automatically
  • Dependencies: illuminate/support ^12.0, livewire/livewire ^4.0

1.7 Wire Up Monorepo

File modified: Root composer.json

Changes:

  1. Added path repository pointing to packages/laraschema
  2. Added "laraschema/laraschema": "@dev" to require
  3. Changed minimum-stability from "stable" to "dev" (required for @dev packages)

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_tables has unique constraint on [project_id, name]
  • laraschema_columns has unique constraint on [table_id, name]
  • All child tables cascade-delete when parent is removed
  • laraschema_projects supports 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 @livewireStyles and @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 /laraschema
  • Canvas β€” Accepts Project model, 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_settings from config defaults
  • Delete uses soft delete (via the SoftDeletes trait 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.escape closes modals
  • x-init="$el.focus(); $el.select()" on rename input auto-focuses and selects text
  • x-on:click.stop on 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:ignore to prevent Livewire DOM-diffing during interactions
  • Grid background: Two <pattern> elements β€” minor-grid (every gridSize px) and major-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 and FK indicators), 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 ID
  • packages/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.js
  • deleteTable() dispatches to Canvas::class specifically (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:

  1. Table Settings β€” Name input (wire:model.blur + wire:change for debounced save), native <input type="color">, three custom toggle switches styled as blue/gray pills with animated knob transition

  2. Columns List β€” Each column is a card with:

    • Reorder arrows (β–²/β–Ό) β€” hidden when at top/bottom boundary
    • Inline name input (wire:blur for update on focus loss)
    • Type dropdown with <optgroup> categories from ColumnType::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
  3. Add Column Form β€” wire:submit="addColumn", includes name input, type select (with type labels), default value input, modifier checkboxes, and submit button

  4. 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 group class 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 unsignedBigInteger with is_foreign = true and is_index = true
  • If onDelete is set 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-created and table-updated events 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:

  1. Create Relationship Modal (shown when $showCreateModal is 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
  2. 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 data
  • canvas.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

  1. Click "Draw Relationship" button β†’ toolbar button turns amber, banner appears
  2. Click source table β†’ amber dashed border appears, preview line follows mouse
  3. Click target table β†’ relationship modal opens with source/target pre-filled
  4. Select type (hasOne, hasMany, belongsTo, belongsToMany), on_delete, on_update
  5. See FK column preview or pivot table name
  6. Click "Create Relationship" β†’ FK column auto-created, SVG line appears with cardinality markers
  7. 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 on on_delete setting
  • 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 MigrationGenerator via 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 $showCodePreview boolean property
  • Added toggleCodePreview() and closeCodePreview() 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 right offset 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 $casts property
  • 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() β€” if email_verified_at column exists
  • trashed() β€” if deleted_at column exists (soft deletes)
  • Status-based β€” if status enum 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 Factory with 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):

  1. Build dependency graph from relationships
  2. BelongsTo β†’ source depends on target
  3. HasOne/HasMany β†’ target depends on source
  4. Calculate in-degree for each table
  5. Queue tables with in-degree = 0
  6. Process queue, decrement dependencies
  7. 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:

  1. 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('...')"
  2. File List (per category):

    • Iterates $this->activeFiles instead of $files
    • Shows files only for active category
    • File selection updates within category
  3. Code View:

    • Uses $this->activeFiles[$activeFile] for content
    • Copy button uses same Alpine.js mechanism

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:

  1. Created test project with 3 tables (users, posts, tags)
  2. Added relationships (users hasMany posts, posts belongsToMany tags)
  3. Clicked "Export ZIP" button
  4. Downloaded ZIP file
  5. 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:

  1. Extracted ZIP into fresh Laravel 12 app
  2. Ran php artisan migrate β†’ All tables created βœ…
  3. Ran php artisan tinker β†’ User::factory()->create() works βœ…
  4. 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.php
  • packages/laraschema/src/Generators/ModelGenerator.php
  • packages/laraschema/src/Generators/FactoryGenerator.php
  • packages/laraschema/src/Generators/SeederGenerator.php
  • packages/laraschema/src/Http/Livewire/CodePreview.php
  • packages/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 $casts property)
  • βœ… Factories use realistic Faker methods
  • βœ… Seeders respect dependency ordering
  • βœ… Complete codebase ready for immediate use

Key achievements:

  1. Generator Pattern β€” Established reusable, testable pattern for code generation
  2. Intelligent Code β€” Heuristic detection for common columns (email, password, slug)
  3. Dependency Ordering β€” Topological sort ensures seeders run in correct order
  4. Complete Codebase β€” Users now get migrations + models + factories + seeders
  5. 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:

  • βœ… LaravelProjectGenerator service β€” orchestrates full project assembly
  • βœ… Base Laravel 12 + Filament 5 template
  • βœ… Pre-generated .env with unique APP_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/finally around all steps guarantees temp directory cleanup even on exception
  • File::ensureDirectoryExists() called before each write batch (no silent failures)
  • DatabaseSeeder.php written separately via writeDatabaseSeeder() 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.php
  • app/Http/Controllers/SetupController.php
  • Updated routes/web.php with setup routes
  • Generated welcome.blade.php with setup wizard
  • Pre-generated .env with unique APP_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 .env or key:generate needed
  • βœ… Browser wizard handles migrate, seed, assets, resource generation
  • βœ… Admin panel ready at /admin after 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 JSON
  • apply_schema β€” non-destructive bulk merge of a full schema
  • create_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-server and self-registers the client via POST /oauth/register. User approves in browser.
  • Claude Desktop / Claude Code β€” still work with a personal access token from /mcp-tokens as a bearer header (validated by the same auth:api guard).

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.com so OAuth/MCP URLs are correct.
  • storage/oauth-private.key / oauth-public.key are gitignored β€” generate them on each environment (or set PASSPORT_PRIVATE_KEY / PASSPORT_PUBLIC_KEY env vars).
  • Sanctum is left installed but unused (guard + trait removed); its personal_access_tokens table 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.json and the table dropped by 2026_06_25_120000_drop_personal_access_tokens_table.php. Sanctum is gone; the only remaining laravel/sanctum string in composer.lock belongs to another package's dev-requires. docs/14-MCP-SERVER-PLAN.md has 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 belongsToMany in the mutator derived both pivot keys from the same table name, so ensureForeignKeyColumn created post_id once and the pivot ended up with a single column. Now parent_id / child_id, matching the editor.
  • The AI prompt asked for setNull / noAction, but MigrationGenerator matches 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 uses false, 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 shared taggables shape (the polymorphic-tags template's) which the editor itself cannot draw, since it derives per-source names (postables, videoables).
  • There is no delete_relationship MCP tool, so the morph-column cleanup in RelationshipManager::deleteRelationship() has no MCP counterpart.
  • hasOneThrough / hasManyThrough remain 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/set requiring values, enum_values accepted 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 for normalizeName() and tableNameRules(), and wrote the column itself, so only MCP was guarded. updateTableName() now delegates to renameTable() and surfaces its refusals on the tableName error 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: true reports 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:5120 upload rule.
  • The response carries inferred_relationship_count and has_foreign_keys, so a dump with no declared FKs β€” where relationships were guessed from *_id naming β€” 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.
  • SchemaDesignerInstructionsTest extended to pin the new prompt contract.

Both suites green: 707 package, 694 root.

Reading the docs because someone has to build this?

If the schema is done and the application around it is the part you would rather hand over, an experienced Laravel team can take it from here.

Talk to a Laravel team
Loading...