├── src ├── Contracts │ ├── SchemaBuilder.php │ ├── ColumnSchema.php │ └── TableSchema.php ├── Template │ ├── Template.php │ ├── Factory.php │ ├── DDL.php │ └── Er.php ├── Helpers.php ├── Schema │ ├── Laravel │ │ ├── SchemaBuilder.php │ │ ├── TableSchema.php │ │ └── ColumnSchema.php │ └── DBAL │ │ ├── SchemaBuilder.php │ │ ├── TableSchema.php │ │ └── ColumnSchema.php ├── Http │ └── Controllers │ │ └── LaravelErdController.php ├── Platform.php ├── Table.php ├── LaravelErdServiceProvider.php ├── Factory.php ├── Pivot.php ├── ModelFinder.php ├── Console │ └── Commands │ │ ├── InstallBinary.php │ │ └── GenerateErd.php ├── ErdFinder.php ├── Relation.php └── RelationFinder.php ├── routes └── web.php ├── config └── laravel-erd.php ├── phpunit.xml ├── database └── migrations │ ├── 2022_11_17_000002_create_mechanics_table.php │ ├── 2022_11_17_000001_create_cars_table.php │ ├── 2022_11_17_000003_create_owners_table.php │ ├── 2022_11_17_000004_create_phones_table.php │ ├── 2022_12_09_000009_create_images_table.php │ ├── 2022_12_09_000008_create_comments_table.php │ ├── 2022_12_09_000007_create_posts_table.php │ ├── 2014_10_12_100000_testbench_create_password_reset_tokens_table.php │ ├── 2022_12_09_000011_create_other_mechanics_table.php │ ├── 2022_12_09_000010_create_other_cars_table.php │ ├── 2022_12_09_000012_create_other_owners_table.php │ ├── 2014_10_12_000000_testbench_create_users_table.php │ ├── 2022_12_09_000013_create_devices_table.php │ ├── 2019_08_19_000000_testbench_create_failed_jobs_table.php │ └── 2024_11_04_000014_create_tasks_table.php ├── LICENSE.md ├── resources ├── views │ ├── svg.blade.php │ └── erd-editor.blade.php └── dist │ └── panzoom.min.js ├── composer.json └── README.md /src/Contracts/SchemaBuilder.php: -------------------------------------------------------------------------------- 1 | name('laravel-erd.show') 8 | ->middleware(config('laravel-erd.middleware')) 9 | ->where('file', '.*'); 10 | -------------------------------------------------------------------------------- /config/laravel-erd.php: -------------------------------------------------------------------------------- 1 | env('LARAVEL_ERD_URI', 'laravel-erd'), 5 | 'storage_path' => storage_path('framework/cache/laravel-erd'), 6 | 'extension' => env('LARAVEL_ERD_EXTENSION', 'sql'), 7 | 'middleware' => [], 8 | 'binary' => [ 9 | 'erd-go' => env('LARAVEL_ERD_GO', '/usr/local/bin/erd-go'), 10 | 'dot' => env('LARAVEL_ERD_DOT', '/usr/local/bin/dot'), 11 | ], 12 | 'connections' => [], 13 | ]; 14 | -------------------------------------------------------------------------------- /src/Contracts/ColumnSchema.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | public function getColumns(): Collection; 15 | 16 | /** 17 | * @return Collection 18 | */ 19 | public function getPrimaryKeys(): Collection; 20 | } 21 | -------------------------------------------------------------------------------- /src/Template/Template.php: -------------------------------------------------------------------------------- 1 | $tables 12 | */ 13 | public function render(Collection $tables): string; 14 | 15 | /** 16 | * @param array $options 17 | */ 18 | public function save(Collection $tables, string $path, array $options = []): int; 19 | } 20 | -------------------------------------------------------------------------------- /src/Helpers.php: -------------------------------------------------------------------------------- 1 | builder = $builder; 16 | } 17 | 18 | public function getTableSchema(string $name): TableSchemaContract 19 | { 20 | return new TableSchema($this->builder, $name); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tests 6 | 7 | 8 | 9 | 10 | src 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /database/migrations/2022_11_17_000002_create_mechanics_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('name'); 19 | }); 20 | } 21 | 22 | /** 23 | * Reverse the migrations. 24 | * 25 | * @return void 26 | */ 27 | public function down() 28 | { 29 | Schema::dropIfExists('mechanics'); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /database/migrations/2022_11_17_000001_create_cars_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('model'); 19 | $table->foreignId('mechanic_id'); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | * 26 | * @return void 27 | */ 28 | public function down() 29 | { 30 | Schema::dropIfExists('cars'); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /database/migrations/2022_11_17_000003_create_owners_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('name'); 19 | $table->foreignId('car_id'); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | * 26 | * @return void 27 | */ 28 | public function down() 29 | { 30 | Schema::dropIfExists('owners'); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /database/migrations/2022_11_17_000004_create_phones_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->foreignId('user_id'); 19 | $table->string('phone_numbers'); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | * 26 | * @return void 27 | */ 28 | public function down() 29 | { 30 | Schema::dropIfExists('phones'); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /database/migrations/2022_12_09_000009_create_images_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('url'); 19 | $table->morphs('imageable'); 20 | $table->timestamps(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::dropIfExists('images'); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /database/migrations/2022_12_09_000008_create_comments_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->foreignId('post_id'); 19 | $table->string('title'); 20 | $table->timestamps(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::dropIfExists('comments'); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/Schema/DBAL/SchemaBuilder.php: -------------------------------------------------------------------------------- 1 | schemaManager = $schemaManager; 17 | } 18 | 19 | /** 20 | * @throws Exception 21 | */ 22 | public function getTableSchema(string $name): TableSchemaContract 23 | { 24 | return new TableSchema($this->schemaManager->introspectTable($name)); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /database/migrations/2022_12_09_000007_create_posts_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->foreignId('user_id'); 19 | $table->string('title')->default('foo')->comment('post title'); 20 | $table->timestamps(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | * 27 | * @return void 28 | */ 29 | public function down() 30 | { 31 | Schema::dropIfExists('posts'); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/Http/Controllers/LaravelErdController.php: -------------------------------------------------------------------------------- 1 | $path]); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /database/migrations/2014_10_12_100000_testbench_create_password_reset_tokens_table.php: -------------------------------------------------------------------------------- 1 | string('email')->primary(); 20 | $table->string('token'); 21 | $table->timestamp('created_at')->nullable(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | */ 28 | public function down(): void 29 | { 30 | Schema::dropIfExists('password_reset_tokens'); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/Template/Factory.php: -------------------------------------------------------------------------------- 1 | */ 10 | private array $templates = [ 11 | 'sql' => DDL::class, 12 | 'er' => Er::class, 13 | 'svg' => Er::class, 14 | ]; 15 | 16 | public function create(string $file): Template 17 | { 18 | $extension = $this->getExtension($file); 19 | $class = $this->templates[$extension] ?? Er::class; 20 | 21 | return new $class; 22 | } 23 | 24 | private function getExtension(string $file): string 25 | { 26 | $extension = substr($file, strrpos($file, '.') + 1); 27 | if (! array_key_exists($extension, $this->templates)) { 28 | throw new RuntimeException('allow ['.implode(',', array_keys($this->templates)).'] only'); 29 | } 30 | 31 | return $extension; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /database/migrations/2022_12_09_000011_create_other_mechanics_table.php: -------------------------------------------------------------------------------- 1 | hasTable('other_mechanics')) { 17 | return; 18 | } 19 | 20 | Schema::connection('other')->create('other_mechanics', function (Blueprint $table) { 21 | $table->id(); 22 | $table->string('name'); 23 | }); 24 | } 25 | 26 | /** 27 | * Reverse the migrations. 28 | * 29 | * @return void 30 | */ 31 | public function down() 32 | { 33 | Schema::connection('other')->dropIfExists('other_mechanics'); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/Platform.php: -------------------------------------------------------------------------------- 1 | hasTable('other_cars')) { 17 | return; 18 | } 19 | 20 | Schema::connection('other')->create('other_cars', function (Blueprint $table) { 21 | $table->id(); 22 | $table->string('model'); 23 | $table->foreignId('mechanic_id'); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::connection('other')->dropIfExists('other_cars'); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /database/migrations/2022_12_09_000012_create_other_owners_table.php: -------------------------------------------------------------------------------- 1 | hasTable('other_owners')) { 17 | return; 18 | } 19 | 20 | Schema::connection('other')->create('other_owners', function (Blueprint $table) { 21 | $table->id(); 22 | $table->string('name'); 23 | $table->foreignId('car_id'); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::connection('other')->dropIfExists('other_owners'); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /database/migrations/2014_10_12_000000_testbench_create_users_table.php: -------------------------------------------------------------------------------- 1 | id(); 20 | $table->string('name'); 21 | $table->string('email')->unique(); 22 | $table->timestamp('email_verified_at')->nullable(); 23 | $table->string('password'); 24 | $table->rememberToken(); 25 | $table->timestamps(); 26 | }); 27 | } 28 | 29 | /** 30 | * Reverse the migrations. 31 | */ 32 | public function down(): void 33 | { 34 | Schema::dropIfExists('users'); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /database/migrations/2022_12_09_000013_create_devices_table.php: -------------------------------------------------------------------------------- 1 | id(); 18 | $table->string('name'); 19 | $table->timestamps(); 20 | }); 21 | 22 | Schema::create('user_device', function (Blueprint $table) { 23 | $table->foreignId('user_id'); 24 | $table->foreignId('device_id'); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | Schema::dropIfExists('devices'); 36 | Schema::dropIfExists('user_device'); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /database/migrations/2019_08_19_000000_testbench_create_failed_jobs_table.php: -------------------------------------------------------------------------------- 1 | id(); 20 | $table->string('uuid')->unique(); 21 | $table->text('connection'); 22 | $table->text('queue'); 23 | $table->longText('payload'); 24 | $table->longText('exception'); 25 | $table->timestamp('failed_at')->useCurrent(); 26 | }); 27 | } 28 | 29 | /** 30 | * Reverse the migrations. 31 | */ 32 | public function down(): void 33 | { 34 | Schema::dropIfExists('failed_jobs'); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /database/migrations/2024_11_04_000014_create_tasks_table.php: -------------------------------------------------------------------------------- 1 | foreignId('category_id'); 18 | }); 19 | 20 | Schema::create('tasks', function (Blueprint $table) { 21 | $table->id(); 22 | $table->foreignId('team_id'); 23 | $table->foreignId('category_id'); 24 | $table->timestamps(); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | * 31 | * @return void 32 | */ 33 | public function down() 34 | { 35 | Schema::dropIfExists('tasks'); 36 | Schema::table('users', function (Blueprint $table) { 37 | $table->dropColumn(['category_id']); 38 | }); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 recca0120 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/Schema/Laravel/TableSchema.php: -------------------------------------------------------------------------------- 1 | builder = $builder; 18 | $this->name = $name; 19 | } 20 | 21 | public function getName(): string 22 | { 23 | return $this->name; 24 | } 25 | 26 | public function getColumns(): Collection 27 | { 28 | return collect($this->builder->getColumns($this->name))->map(fn (array $column) => new ColumnSchema($column)); 29 | } 30 | 31 | public function getPrimaryKeys(): Collection 32 | { 33 | return collect($this->builder->getIndexes($this->name)) 34 | ->filter(fn (array $column) => $column['primary'] === true) 35 | ->map(fn (array $column) => $column['columns']) 36 | ->collapse(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Table.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | private Collection $relations; 17 | 18 | public function __construct(TableSchema $schema, Collection $relations) 19 | { 20 | $this->schema = $schema; 21 | $this->relations = $relations; 22 | } 23 | 24 | public function getName(): string 25 | { 26 | return $this->schema->getName(); 27 | } 28 | 29 | /** 30 | * @return Collection 31 | */ 32 | public function getColumns(): Collection 33 | { 34 | return $this->schema->getColumns(); 35 | } 36 | 37 | public function getPrimaryKeys(): Collection 38 | { 39 | return $this->schema->getPrimaryKeys(); 40 | } 41 | 42 | /** 43 | * @return Collection 44 | */ 45 | public function getRelations(): Collection 46 | { 47 | return $this->relations; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Schema/DBAL/TableSchema.php: -------------------------------------------------------------------------------- 1 | table = $table; 18 | } 19 | 20 | public function getName(): string 21 | { 22 | return $this->table->getName(); 23 | } 24 | 25 | /** 26 | * @return Collection 27 | */ 28 | public function getColumns(): Collection 29 | { 30 | return collect($this->table->getColumns())->map(fn (DBALColumn $column) => new ColumnSchema($column)); 31 | } 32 | 33 | public function getPrimaryKeys(): Collection 34 | { 35 | return collect($this->table->getIndexes()) 36 | ->filter(fn (Index $index) => $index->isPrimary()) 37 | ->map(fn (Index $index) => $index->getColumns()) 38 | ->collapse(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Schema/Laravel/ColumnSchema.php: -------------------------------------------------------------------------------- 1 | column = $column; 14 | } 15 | 16 | public function getName(): string 17 | { 18 | return $this->column['name']; 19 | } 20 | 21 | public function isNullable(): bool 22 | { 23 | return $this->column['nullable']; 24 | } 25 | 26 | public function getPrecision(): int 27 | { 28 | $lookup = ['varchar' => 255, 'datetime' => 0, 'integer' => 11]; 29 | 30 | return $lookup[$this->getType()] ?? 0; 31 | } 32 | 33 | public function getType(): string 34 | { 35 | return $this->column['type']; 36 | } 37 | 38 | public function getDefault() 39 | { 40 | $default = $this->column['default']; 41 | 42 | return $default ? trim($default, "'") : $default; 43 | } 44 | 45 | public function getComment(): ?string 46 | { 47 | return $this->column['comment'] ?? null; 48 | } 49 | 50 | public function isAutoIncrement(): bool 51 | { 52 | return $this->column['auto_increment']; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/LaravelErdServiceProvider.php: -------------------------------------------------------------------------------- 1 | mergeConfigFrom(__DIR__.'/../config/laravel-erd.php', 'laravel-erd'); 16 | $this->loadViewsFrom(__DIR__.'/../resources/views', 'laravel-erd'); 17 | $this->loadRoutesFrom(__DIR__.'/../routes/web.php'); 18 | 19 | if ($this->app->runningInConsole()) { 20 | $this->publishes([ 21 | __DIR__.'/../config/laravel-erd.php' => config_path('laravel-erd.php'), 22 | __DIR__.'/../resources/dist' => public_path('vendor/laravel-erd'), 23 | __DIR__.'/../resources/views' => resource_path('views/vendor/laravel-erd'), 24 | ], 'laravel-erd'); 25 | } 26 | 27 | $this->app->singleton(Factory::class, Factory::class); 28 | $this->app->when(InstallBinary::class) 29 | ->needs(ClientInterface::class) 30 | ->give(Client::class); 31 | 32 | $this->commands([InstallBinary::class, GenerateErd::class]); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Factory.php: -------------------------------------------------------------------------------- 1 | connectionResolver = $connectionResolver; 18 | } 19 | 20 | public function create(?string $name = null): ErdFinder 21 | { 22 | $key = $name ?: 'default'; 23 | 24 | if (! empty($this->cache[$key])) { 25 | return $this->cache[$key]; 26 | } 27 | 28 | return $this->cache[$key] = new ErdFinder( 29 | $this->getSchemaBuilder($name), 30 | new ModelFinder($name), 31 | new RelationFinder 32 | ); 33 | } 34 | 35 | /** 36 | * @return DBALSchemaBuilder|LaravelSchemaBuilder 37 | */ 38 | private function getSchemaBuilder(?string $name) 39 | { 40 | $connection = $this->connectionResolver->connection($name); 41 | 42 | return method_exists($connection, 'getDoctrineSchemaManager') 43 | ? new DBALSchemaBuilder($connection->getDoctrineSchemaManager()) 44 | : new LaravelSchemaBuilder($connection->getSchemaBuilder()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Schema/DBAL/ColumnSchema.php: -------------------------------------------------------------------------------- 1 | column = $column; 17 | } 18 | 19 | public function getName(): string 20 | { 21 | return $this->column->getName(); 22 | } 23 | 24 | public function isNullable(): bool 25 | { 26 | return ! $this->column->getNotnull(); 27 | } 28 | 29 | public function getPrecision(): int 30 | { 31 | // return $this->column->getPrecision(); 32 | $lookup = ['varchar' => 255, 'datetime' => 0, 'integer' => 11]; 33 | 34 | return $lookup[$this->getType()] ?? 0; 35 | } 36 | 37 | public function getDefault() 38 | { 39 | return $this->column->getDefault(); 40 | } 41 | 42 | public function getComment(): ?string 43 | { 44 | return $this->column->getComment(); 45 | } 46 | 47 | public function isAutoIncrement(): bool 48 | { 49 | return $this->column->getAutoincrement(); 50 | } 51 | 52 | public function getType(): string 53 | { 54 | try { 55 | $type = Type::getTypeRegistry()->lookupName($this->column->getType()); 56 | 57 | return $type === 'string' ? 'varchar' : $type; 58 | } catch (Exception $e) { 59 | return 'unknown'; 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /resources/views/svg.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | Laravel Erd 9 | 19 | 20 | 21 | @php 22 | $svg = new \DOMDocument(); 23 | $svg->load($path); 24 | $svg->documentElement->setAttribute("id", 'svg'); 25 | echo $svg->saveXML($svg->documentElement); 26 | @endphp 27 | 61 | 62 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "recca0120/laravel-erd", 3 | "description": "Laravel ERD automatically generates Entity-Relationship Diagrams from your Laravel models and displays them using Vuerd.", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "recca0120", 9 | "email": "recca0120@gmail.com" 10 | } 11 | ], 12 | "autoload": { 13 | "psr-4": { 14 | "Recca0120\\LaravelErd\\": "src/" 15 | } 16 | }, 17 | "autoload-dev": { 18 | "psr-4": { 19 | "Recca0120\\LaravelErd\\Tests\\": "tests/" 20 | } 21 | }, 22 | "require": { 23 | "ext-sqlite3": "*", 24 | "illuminate/collections": "^8.0|^9.0|^10.0|^11.0|^12.0", 25 | "illuminate/database": "^8.0|^9.0|^10.0|^11.0|^12.0", 26 | "illuminate/filesystem": "^8.0|^9.0|^10.0|^11.0|^12.0", 27 | "nikic/php-parser": "^5.1.0", 28 | "php-http/client-common": "^2.7" 29 | }, 30 | "require-dev": { 31 | "awobaz/compoships": "^2.3", 32 | "doctrine/dbal": "^3.5", 33 | "guzzlehttp/guzzle": "^7.5", 34 | "mockery/mockery": "^1.5", 35 | "orchestra/testbench": "^6.25|^7.13|^8.0|^9.0|^10.0", 36 | "php-http/mock-client": "^1.6", 37 | "phpunit/phpunit": "^9.5|^10.0|^11.0|^12.0", 38 | "spatie/laravel-permission": "^5.7|^6.0", 39 | "spatie/phpunit-snapshot-assertions": "^4.2|^5.1.6" 40 | }, 41 | "scripts": { 42 | "post-autoload-dump": [ 43 | "@php vendor/bin/testbench package:discover --ansi" 44 | ] 45 | }, 46 | "config": { 47 | "preferred-install": "dist", 48 | "sort-packages": true, 49 | "optimize-autoloader": true, 50 | "allow-plugins": { 51 | "php-http/discovery": true 52 | } 53 | }, 54 | "extra": { 55 | "laravel": { 56 | "providers": [ 57 | "Recca0120\\LaravelErd\\LaravelErdServiceProvider" 58 | ] 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Pivot.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | private array $attributes; 11 | 12 | /** 13 | * @param array $pivot 14 | */ 15 | public function __construct(array $pivot) 16 | { 17 | $this->attributes = $pivot; 18 | } 19 | 20 | public function type(): string 21 | { 22 | return $this->attributes['type']; 23 | } 24 | 25 | public function related(): string 26 | { 27 | return $this->attributes['related']; 28 | } 29 | 30 | public function parent(): string 31 | { 32 | return $this->attributes['parent']; 33 | } 34 | 35 | public function localKey(): string 36 | { 37 | return $this->attributes['local_key']; 38 | } 39 | 40 | public function localTable(): string 41 | { 42 | return Helpers::getTableName($this->attributes['local_key']); 43 | } 44 | 45 | public function foreignKey(): string 46 | { 47 | return $this->attributes['foreign_key']; 48 | } 49 | 50 | public function foreignTable(): string 51 | { 52 | return Helpers::getTableName($this->attributes['foreign_key']); 53 | } 54 | 55 | public function morphClass(): string 56 | { 57 | return $this->attributes['morph_class'] ?? ''; 58 | } 59 | 60 | public function morphType(): string 61 | { 62 | return $this->attributes['morph_type'] ?? ''; 63 | } 64 | 65 | public function connection(): ?string 66 | { 67 | $model = $this->related(); 68 | 69 | return (new $model)->getConnectionName(); 70 | } 71 | 72 | /** 73 | * @return array 74 | */ 75 | public function toArray(): array 76 | { 77 | return $this->attributes; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /resources/views/erd-editor.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | Laravel Erd 9 | 15 | 16 | 17 | 18 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/ModelFinder.php: -------------------------------------------------------------------------------- 1 | connection = $connection; 29 | $parserFactory = new ParserFactory; 30 | $this->parser = $parserFactory->createForNewestSupportedVersion(); 31 | } 32 | 33 | /** 34 | * @param string|string[] $regex 35 | */ 36 | public function find(string $directory, $regex = '*.php'): Collection 37 | { 38 | $files = Finder::create()->files()->name($regex)->in($directory); 39 | 40 | return collect($files) 41 | ->map(fn (SplFileInfo $file) => $this->getFullyQualifiedClassName($file)) 42 | ->filter(fn (?string $className) => $className && self::isEloquentModel($className)) 43 | ->filter(fn (string $className) => (new $className)->getConnectionName() === $this->connection) 44 | ->values(); 45 | } 46 | 47 | private static function isEloquentModel(string $className): bool 48 | { 49 | try { 50 | return $className && 51 | is_subclass_of($className, Model::class) && 52 | ! (new ReflectionClass($className))->isAbstract(); 53 | } catch (Throwable $e) { 54 | return false; 55 | } 56 | } 57 | 58 | private function getFullyQualifiedClassName(SplFileInfo $file): ?string 59 | { 60 | $nodeTraverser = new NodeTraverser; 61 | $nodeTraverser->addVisitor(new NameResolver); 62 | $nodes = $nodeTraverser->traverse($this->parser->parse($file->getContents())); 63 | 64 | /** @var ?Namespace_ $rootNode */ 65 | $rootNode = collect($nodes)->first(fn (Node $node) => $node instanceof Namespace_); 66 | 67 | return ! $rootNode 68 | ? null 69 | : collect($rootNode->stmts) 70 | ->filter(static fn (Stmt $stmt) => $stmt instanceof Class_) 71 | ->map(static fn (Class_ $stmt) => $stmt->namespacedName->toString()) 72 | ->first(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel ERD 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/recca0120/laravel-erd.svg?style=flat-square)](https://packagist.org/packages/recca0120/laravel-erd) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/recca0120/laravel-erd/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/recca0120/laravel-erd/actions?query=workflow%3Arun-tests+branch%3Amain) 5 | [![Total Downloads](https://img.shields.io/packagist/dt/recca0120/laravel-erd.svg?style=flat-square)](https://packagist.org/packages/recca0120/laravel-erd) 6 | 7 | Laravel ERD automatically generates Entity-Relationship Diagrams from your Laravel models and displays them 8 | using [erd-editor](https://github.com/dineug/erd-editor). 9 | 10 | ## Preview 11 | 12 | Here's a sample of what you can expect, generated from [migrations](database/migrations) 13 | and [models](tests/Fixtures/Models): 14 | 15 | > [View Live Demo](https://rawcdn.githack.com/recca0120/laravel-erd/c936d64543139b70615333c833077a0076949dc8/demo/index.html) 16 | 17 | ![erd-editor](demo/erd-editor.png) 18 | 19 | ## Requirements 20 | 21 | | Lang | Version | 22 | |:--------|:------------------------| 23 | | PHP | 7.4, 8.0, 8.1, 8.2, 8.3 | 24 | | Laravel | 8, 9, 10, 11, 12 | 25 | 26 | ## Installation 27 | 28 | Install the package via Composer: 29 | 30 | ```bash 31 | composer require recca0120/laravel-erd:^0.4 --dev 32 | ``` 33 | 34 | ## Usage 35 | 36 | ### Step 1: Generate the ERD 37 | 38 | Run the following command: 39 | 40 | ```bash 41 | php artisan erd:generate 42 | ``` 43 | 44 | Step 2: View the ERD 45 | 46 | Open the following URL in your browser: 47 | 48 | http://localhost/laravel-erd 49 | 50 | ## Advanced Usage 51 | 52 | ### Exclude Tables and Save to a Different Filename 53 | 54 | #### step 1. 55 | 56 | Run the command: 57 | 58 | ```bash 59 | php artisan erd:generate --file=exclude-users.sql --excludes=users 60 | ``` 61 | 62 | #### step 2. 63 | 64 | Open the URL: 65 | 66 | http://localhost/laravel-erd/exclude-users 67 | 68 | ### Generate an SVG Version 69 | 70 | #### step 1. 71 | 72 | Install [erd-go](https://github.com/kaishuu0123/erd-go) 73 | and [graphviz-dot.js](https://github.com/kaishuu0123/graphviz-dot.js) using: 74 | 75 | ```bash 76 | php artisan erd:install 77 | ``` 78 | 79 | #### step 2. 80 | 81 | Generate the SVG file: 82 | 83 | ```php 84 | php artisan erd:generate --file=laravel-erd.svg 85 | ``` 86 | 87 | #### step 3. 88 | 89 | View the SVG version: 90 | 91 | http://localhost/laravel-erd/laravel-erd.svg 92 | 93 | ![svg](tests/Fixtures/expected_artisan.svg) 94 | 95 | > The SVG file can be found at storage/framework/cache/laravel-erd. 96 | 97 | Feel free to ask if you have any questions or need further assistance! 98 | -------------------------------------------------------------------------------- /src/Template/DDL.php: -------------------------------------------------------------------------------- 1 | map(fn (Table $table) => sprintf( 16 | "CREATE TABLE %s (\n%s\n)", 17 | $table->getName(), 18 | $this->renderColumn($table) 19 | )) 20 | ->merge($this->renderRelations($tables)) 21 | ->implode("\n"); 22 | } 23 | 24 | public function save(Collection $tables, string $path, array $options = []): int 25 | { 26 | return (int) file_put_contents($path, $this->render($tables)); 27 | } 28 | 29 | private function renderColumn(Table $table): string 30 | { 31 | return $table->getColumns() 32 | ->map(function (ColumnSchema $column) { 33 | $type = $column->getType(); 34 | $precision = $column->getPrecision(); 35 | $default = $column->getDefault(); 36 | $comment = $column->getComment(); 37 | 38 | return implode(' ', array_filter([ 39 | $column->getName(), 40 | $type.($precision ? "({$precision})" : ''), 41 | $column->isNullable() ? '' : 'NOT NULL', 42 | $default ? "DEFAULT {$default}" : '', 43 | $comment ? "COMMENT {$comment}" : '', 44 | $column->isAutoIncrement() ? 'AUTO_INCREMENT' : '', 45 | ])); 46 | }) 47 | ->merge($this->renderPrimaryKeys($table)) 48 | ->filter() 49 | ->map(fn (string $line) => ' '.$line) 50 | ->implode(",\n"); 51 | } 52 | 53 | /** 54 | * @return string[] 55 | */ 56 | private function renderPrimaryKeys(Table $table): array 57 | { 58 | $primaryKeys = $table->getPrimaryKeys()->implode(', '); 59 | 60 | return $primaryKeys ? ["PRIMARY KEY({$primaryKeys})"] : []; 61 | } 62 | 63 | /** 64 | * @param Collection $tables 65 | * @return Collection 66 | */ 67 | private function renderRelations(Collection $tables): Collection 68 | { 69 | return $tables 70 | ->map(fn (Table $table) => $table->getRelations()) 71 | ->collapse() 72 | ->unique(fn (Relation $relation) => $relation->uniqueId()) 73 | ->map(fn (Relation $relation) => $this->renderRelation($relation)) 74 | ->sort(); 75 | } 76 | 77 | private function renderRelation(Relation $relation): string 78 | { 79 | return sprintf( 80 | 'ALTER TABLE %s ADD FOREIGN KEY (%s) REFERENCES %s (%s)', 81 | $relation->localTable(), 82 | implode(', ', $relation->localColumns()), 83 | $relation->foreignTable(), 84 | implode(', ', $relation->foreignColumns()) 85 | ); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Console/Commands/InstallBinary.php: -------------------------------------------------------------------------------- 1 | client = $client; 32 | $this->platform = $platform; 33 | } 34 | 35 | public function handle(): int 36 | { 37 | $config = config('laravel-erd.binary'); 38 | 39 | try { 40 | $platform = $this->platform->platform(); 41 | $arch = $this->platform->arch(); 42 | $this->downloadErdGo($platform, $arch, $config['erd-go']); 43 | $this->downloadDot($platform, $arch, $config['dot']); 44 | 45 | return self::SUCCESS; 46 | } catch (ClientExceptionInterface $e) { 47 | $this->error($e->getMessage()); 48 | 49 | return self::FAILURE; 50 | } 51 | } 52 | 53 | /** 54 | * @throws ClientExceptionInterface 55 | */ 56 | private function downloadErdGo(string $platform, string $arch, string $path): void 57 | { 58 | $extension = $platform === Platform::WINDOWS ? '.exe' : ''; 59 | $arch = $platform === Platform::LINUX && $arch === Platform::ARM ? Platform::ARM : 'amd64'; 60 | 61 | $url = self::ERD_GO_DOWNLOAD_URL.'%s_%s_erd-go%s'; 62 | $this->download(sprintf($url, $platform, $arch, $extension), $path); 63 | } 64 | 65 | /** 66 | * @throws RequestException 67 | * @throws ClientExceptionInterface 68 | */ 69 | private function downloadDot(string $platform, string $arch, string $path): void 70 | { 71 | $extension = $platform === Platform::WINDOWS ? '.exe' : ''; 72 | $arch = $arch === Platform::ARM ? '64' : $arch; 73 | $lookup = [Platform::DARWIN => 'macos', Platform::WINDOWS => 'win']; 74 | 75 | $url = self::DOT_DOWNLOAD_URL.'graphviz-dot-%s-x%s%s'; 76 | $this->download(sprintf($url, $lookup[$platform] ?? $platform, $arch, $extension), $path); 77 | } 78 | 79 | /** 80 | * @throws ClientExceptionInterface 81 | */ 82 | private function download(string $url, string $path): void 83 | { 84 | if (File::exists($path)) { 85 | return; 86 | } 87 | 88 | $this->line('download: '.$url); 89 | File::ensureDirectoryExists(dirname($path)); 90 | 91 | $request = new Request('GET', $url); 92 | $plugins = [new ErrorPlugin, new RedirectPlugin]; 93 | $response = (new PluginClient($this->client, $plugins))->sendRequest($request); 94 | 95 | File::put($path, (string) $response->getBody()); 96 | File::chmod($path, 0777); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Template/Er.php: -------------------------------------------------------------------------------- 1 | '1--*', 26 | MorphTo::class => '1--*', 27 | HasOne::class => '1--1', 28 | MorphOne::class => '1--1', 29 | HasMany::class => '1--*', 30 | MorphMany::class => '1--*', 31 | BelongsToMany::class => '1--*', 32 | MorphToMany::class => '1--*', 33 | ]; 34 | 35 | private ExecutableFinder $finder; 36 | 37 | public function __construct() 38 | { 39 | $this->finder = new ExecutableFinder; 40 | } 41 | 42 | public function render(Collection $tables): string 43 | { 44 | $results = $tables->map(fn (Table $table): string => $this->renderTable($table)); 45 | $relations = $tables->flatMap(fn (Table $table) => $table->getRelations()); 46 | 47 | return $results->merge( 48 | $relations 49 | ->unique(fn (Relation $relation) => $relation->uniqueId()) 50 | ->map(fn (Relation $relationship) => $this->renderRelation($relationship)) 51 | ->sort() 52 | )->implode("\n"); 53 | } 54 | 55 | public function save(Collection $tables, string $path, array $options = []): int 56 | { 57 | $fp = fopen(str_replace('.svg', '.er', $path), 'wb'); 58 | fwrite($fp, $this->render($tables)); 59 | $meta = stream_get_meta_data($fp); 60 | fclose($fp); 61 | 62 | $process = Process::fromShellCommandline($this->getCommand($options, $meta['uri'], $path)); 63 | 64 | $process->run(); 65 | $exitCode = $process->wait(); 66 | 67 | $errorOutput = $process->getErrorOutput(); 68 | if (! empty($errorOutput)) { 69 | throw new RuntimeException($errorOutput); 70 | } 71 | 72 | return $exitCode; 73 | } 74 | 75 | private function renderTable(Table $table): string 76 | { 77 | $primaryKeys = $table->getPrimaryKeys(); 78 | $indexes = $table 79 | ->getRelations() 80 | ->flatMap(fn (Relation $relation) => [...$relation->localColumns(), $relation->morphColumn()]) 81 | ->filter() 82 | ->unique(); 83 | 84 | return $table->getColumns() 85 | ->map(fn (ColumnSchema $column) => $this->renderColumn($column, $primaryKeys, $indexes)) 86 | ->prepend(sprintf('[%s] {}', $table->getName())) 87 | ->implode("\n")."\n"; 88 | } 89 | 90 | private function renderColumn(ColumnSchema $column, Collection $primaryKeys, Collection $indexes): string 91 | { 92 | $type = $column->getType(); 93 | 94 | return sprintf( 95 | '%s%s%s {label: "%s, %s"}', 96 | $primaryKeys->containsStrict($column->getName()) ? '*' : '', 97 | $indexes->containsStrict($column->getName()) ? '+' : '', 98 | $column->getName(), 99 | $type === 'varchar' ? 'string' : $type, 100 | $column->isNullable() ? 'null' : 'not null' 101 | ); 102 | } 103 | 104 | private function renderRelation(Relation $relation): string 105 | { 106 | return sprintf( 107 | '%s %s %s', 108 | $relation->localTable(), 109 | self::$relationships[$relation->type()], 110 | $relation->foreignTable() 111 | ); 112 | } 113 | 114 | private function getCommand($option, string $uri, string $path): string 115 | { 116 | $binary = $option['binary']; 117 | $erdGo = is_executable($binary['erd-go']) ? $binary['erd-go'] : $this->finder->find('erd-go'); 118 | $dot = is_executable($binary['dot']) ? $binary['dot'] : $this->finder->find('dot'); 119 | 120 | return sprintf('cat %s | %s | %s -T svg > "%s"', $uri, $erdGo, $dot, $path); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Console/Commands/GenerateErd.php: -------------------------------------------------------------------------------- 1 | argument('database'); 33 | $directory = $this->option('directory') ?: app_path(); 34 | $regex = trim($this->option('regex'), "\"'"); 35 | $excludes = preg_split('/\s*,\s*/', $this->option('excludes') ?? ''); 36 | $file = $this->getFile($config, $database); 37 | 38 | try { 39 | $this->setupFakeDatabase($database); 40 | 41 | if ($this->runMigrate($database) === self::FAILURE) { 42 | return self::FAILURE; 43 | } 44 | 45 | $tables = $factory 46 | ->create($database) 47 | ->in($directory) 48 | ->find($regex, $excludes); 49 | 50 | $templateFactory 51 | ->create($file) 52 | ->save($tables, $file, $config); 53 | 54 | return self::SUCCESS; 55 | } catch (Throwable $e) { 56 | if (! $this->option('graceful')) { 57 | throw $e; 58 | } 59 | 60 | $this->error($e->getMessage()); 61 | 62 | return self::FAILURE; 63 | } finally { 64 | $this->restoreDatabase($database); 65 | } 66 | } 67 | 68 | private function runMigrate(?string $database): int 69 | { 70 | $default = config('database.default'); 71 | $arguments = array_filter([ 72 | '--database' => $default === $database ? null : $database, 73 | '--path' => $this->option('path'), 74 | ]); 75 | 76 | $output = new BufferedOutput; 77 | if ($this->runCommand('migrate', $arguments, $output) === self::FAILURE) { 78 | $this->error($output->fetch()); 79 | 80 | return self::FAILURE; 81 | } 82 | 83 | return self::SUCCESS; 84 | } 85 | 86 | private function setupFakeDatabase(?string $database): void 87 | { 88 | $default = config('database.default'); 89 | $database = $database ?? $default; 90 | $connections = config('laravel-erd.connections'); 91 | 92 | $this->backup['cache.default'] = config('cache.default'); 93 | $this->backup['database.connections'] = config('database.connections'); 94 | 95 | config(['cache.default' => 'array']); 96 | config(Arr::dot(array_map(static fn (array $config) => $connections[$database] ?? [ 97 | 'driver' => 'sqlite', 98 | 'database' => ':memory:', 99 | 'prefix' => $config['prefix'] ?? '', 100 | 'foreign_key_constraints' => true, 101 | 'busy_timeout' => null, 102 | 'journal_mode' => null, 103 | 'synchronous' => null, 104 | ], $this->backup['database.connections']), 'database.connections.')); 105 | 106 | DB::purge($database); 107 | } 108 | 109 | private function restoreDatabase(?string $database): void 110 | { 111 | $default = config('database.default'); 112 | $arguments = array_filter([ 113 | '--database' => $default === $database ? null : $database, 114 | '--path' => $this->option('path'), 115 | ]); 116 | 117 | $output = new BufferedOutput; 118 | $this->runCommand('migrate:rollback', $arguments, $output); 119 | 120 | DB::purge($database); 121 | 122 | config(['cache.default' => $this->backup['cache.default']]); 123 | config(['database.connections' => $this->backup['database.connections']]); 124 | $this->backup = []; 125 | } 126 | 127 | private function getFile(array $config, ?string $database): string 128 | { 129 | $path = $config['storage_path'] ?? storage_path('framework/cache/laravel-erd'); 130 | File::ensureDirectoryExists($path); 131 | 132 | $file = $this->option('file') ?? $database; 133 | $file = $file ?? config('database.default'); 134 | $file = ! File::extension($file) ? $file.'.'.($config['extension'] ?? 'sql') : $file; 135 | 136 | return $path.'/'.$file; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/ErdFinder.php: -------------------------------------------------------------------------------- 1 | schemaBuilder = $schemaBuilder; 21 | $this->modelFinder = $modelFinder; 22 | $this->relationFinder = $relationFinder; 23 | } 24 | 25 | public function in(string $directory): ErdFinder 26 | { 27 | $this->directory = $directory; 28 | 29 | return $this; 30 | } 31 | 32 | /** 33 | * @param string|string[] $regex 34 | * @param string[] $excludes 35 | * @return Collection 36 | * 37 | * @throws ReflectionException 38 | */ 39 | public function find($regex = '*.php', array $excludes = []): Collection 40 | { 41 | $models = $this->modelFinder->find($this->directory ?? __DIR__, $regex); 42 | 43 | return $this->findByModels($models, $excludes); 44 | } 45 | 46 | /** 47 | * @param string|string[] $file 48 | * @param string[] $excludes 49 | * @return Collection 50 | * 51 | * @throws ReflectionException 52 | */ 53 | public function findByFile($file, array $excludes = []): Collection 54 | { 55 | $models = $this->modelFinder->find($this->directory ?? __DIR__, $file); 56 | 57 | return $this->findByModels($models, $excludes); 58 | } 59 | 60 | /** 61 | * @param string[] $excludes 62 | * @return Collection 63 | * 64 | * @throws ReflectionException 65 | */ 66 | public function findByModel(string $className, array $excludes = []): Collection 67 | { 68 | return $this->findByModels(collect($className), $excludes); 69 | } 70 | 71 | /** 72 | * @param string[] $excludes 73 | * @return Collection 74 | * 75 | * @throws ReflectionException 76 | */ 77 | private function findByModels(Collection $models, array $excludes = []): Collection 78 | { 79 | $models = $this->mergeMissing($models); 80 | $relations = $models 81 | ->flatMap(fn (string $model) => $this->relationFinder->generate($model)->collapse()) 82 | ->flatMap(fn (Relation $relation) => [$relation, $relation->relatedRelation()]); 83 | 84 | $relationGroupByConnection = $relations 85 | ->groupBy(fn (Relation $relation) => $relation->connection()) 86 | ->map(function (Collection $relations) use ($excludes) { 87 | return $relations 88 | ->reject(fn (Relation $relation) => $relation->excludes($excludes)) 89 | ->sortBy(fn (Relation $relation) => $relation->sortByKeys()) 90 | ->unique(fn (Relation $relation) => $relation->sortByKeys()) 91 | ->groupBy(fn (Relation $relation) => $relation->localTable()); 92 | }); 93 | 94 | return $models 95 | ->map(fn (string $model) => new $model) 96 | ->map(fn (Model $model) => [ 97 | 'connection' => $model->getConnectionName(), 98 | 'table' => $model->getTable(), 99 | 'related' => get_class($model), 100 | ]) 101 | ->merge($relations->flatMap(function (Relation $relation) { 102 | $related = $relation->related(); 103 | $connection = $relation->connection(); 104 | 105 | return array_map(static fn (string $table) => [ 106 | 'connection' => $connection, 107 | 'table' => $table, 108 | 'related' => $related, 109 | ], [$relation->foreignTable()]); 110 | })) 111 | ->unique(fn (array $data) => [$data['connection'], $data['table']]) 112 | ->reject(fn (array $data) => in_array($data['table'], $excludes, true)) 113 | ->sortBy(fn (array $data) => [$data['connection'], $data['table']]) 114 | ->map(fn (array $data) => array_merge($data, [ 115 | 'relations' => $relationGroupByConnection 116 | ->get($data['connection'], collect()) 117 | ->get($data['table'], collect()), 118 | ])) 119 | ->map(function (array $data) { 120 | return new Table($this->schemaBuilder->getTableSchema($data['table']), $data['relations']); 121 | }); 122 | } 123 | 124 | private function mergeMissing(Collection $models): Collection 125 | { 126 | return $models->merge($models 127 | ->flatMap(fn (string $model) => $this->relationFinder->generate($model)->collapse()) 128 | ->map(fn (Relation $relation) => $relation->related()) 129 | ->filter() 130 | ->diff($models)); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Relation.php: -------------------------------------------------------------------------------- 1 | BelongsTo::class, 16 | \Awobaz\Compoships\Database\Eloquent\Relations\HasOne::class => HasOne::class, 17 | \Awobaz\Compoships\Database\Eloquent\Relations\HasMany::class => HasMany::class, 18 | ]; 19 | 20 | private array $attributes; 21 | 22 | public function __construct(array $attributes) 23 | { 24 | $this->attributes = $attributes; 25 | } 26 | 27 | public function type(): string 28 | { 29 | return self::$relationMap[$this->attributes['type']] ?? $this->attributes['type']; 30 | } 31 | 32 | public function related(): string 33 | { 34 | return $this->attributes['related']; 35 | } 36 | 37 | public function parent(): string 38 | { 39 | return $this->attributes['parent']; 40 | } 41 | 42 | public function localKeys(): array 43 | { 44 | return (array) $this->attributes['local_key']; 45 | } 46 | 47 | public function localTable(): string 48 | { 49 | return Helpers::getTableName($this->localKeys()[0]); 50 | } 51 | 52 | public function localColumns(): array 53 | { 54 | return array_map(static function (string $column) { 55 | return Helpers::getColumnName($column); 56 | }, $this->localKeys()); 57 | } 58 | 59 | public function foreignKeys(): array 60 | { 61 | return (array) $this->attributes['foreign_key']; 62 | } 63 | 64 | public function foreignTable(): string 65 | { 66 | return Helpers::getTableName($this->foreignKeys()[0]); 67 | } 68 | 69 | public function foreignColumns(): array 70 | { 71 | return array_map(static function (string $column) { 72 | return Helpers::getColumnName($column); 73 | }, $this->foreignKeys()); 74 | } 75 | 76 | public function morphClass(): ?string 77 | { 78 | return $this->attributes['morph_class'] ?? null; 79 | } 80 | 81 | public function morphType(): ?string 82 | { 83 | return $this->attributes['morph_type'] ?? null; 84 | } 85 | 86 | public function morphColumn(): ?string 87 | { 88 | return Helpers::getColumnName($this->morphType()); 89 | } 90 | 91 | public function connection(): ?string 92 | { 93 | $model = $this->related(); 94 | 95 | return (new $model)->getConnectionName(); 96 | } 97 | 98 | public function pivot(): ?Pivot 99 | { 100 | return array_key_exists('pivot', $this->attributes) 101 | ? new Pivot($this->attributes['pivot']) 102 | : null; 103 | } 104 | 105 | /** 106 | * @param string[] $tables 107 | */ 108 | public function excludes(array $tables): bool 109 | { 110 | $localTable = $this->localTable(); 111 | $foreignTable = $this->foreignTable(); 112 | 113 | return in_array($localTable, $tables, true) || in_array($foreignTable, $tables, true); 114 | } 115 | 116 | public function relatedRelation(): Relation 117 | { 118 | $reverseLookup = [ 119 | BelongsTo::class => HasMany::class, 120 | HasOne::class => BelongsTo::class, 121 | MorphOne::class => MorphTo::class, 122 | HasMany::class => BelongsTo::class, 123 | MorphMany::class => MorphTo::class, 124 | ]; 125 | 126 | $type = $this->type(); 127 | 128 | return new Relation(array_filter([ 129 | 'type' => $reverseLookup[$type] ?? $type, 130 | 'related' => $this->parent(), 131 | 'parent' => $this->related(), 132 | 'local_key' => $this->foreignKeys(), 133 | 'foreign_key' => $this->localKeys(), 134 | 'pivot' => $this->attributes['pivot'] ?? null, 135 | 'morph_class' => $this->morphClass(), 136 | 'morph_type' => $this->morphType(), 137 | ])); 138 | } 139 | 140 | public function sortByRelation(): int 141 | { 142 | $relationGroups = [ 143 | [BelongsTo::class, HasOne::class, MorphOne::class], 144 | [HasMany::class, MorphMany::class], 145 | ]; 146 | 147 | $type = $this->type(); 148 | foreach ($relationGroups as $index => $relations) { 149 | if (in_array($type, $relations, true)) { 150 | return $index + 1; 151 | } 152 | } 153 | 154 | return count($relationGroups); 155 | } 156 | 157 | /** 158 | * @return string[] 159 | */ 160 | public function sortByKeys(): array 161 | { 162 | return [$this->type(), $this->localKeys(), $this->foreignKeys()]; 163 | } 164 | 165 | public function uniqueId(): string 166 | { 167 | $sortBy = []; 168 | foreach ($this->localKeys() as $localKey) { 169 | $sortBy[] = Helpers::getTableName($localKey); 170 | } 171 | foreach ($this->foreignKeys() as $foreignKey) { 172 | $sortBy[] = Helpers::getTableName($foreignKey); 173 | } 174 | 175 | sort($sortBy); 176 | 177 | return implode('::', $sortBy); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/RelationFinder.php: -------------------------------------------------------------------------------- 1 | $className 25 | * @return Collection|null> 26 | * 27 | * @throws ReflectionException 28 | */ 29 | public function generate(string $className): Collection 30 | { 31 | $class = new ReflectionClass($className); 32 | $model = new $className; 33 | 34 | return collect($class->getMethods(ReflectionMethod::IS_PUBLIC)) 35 | ->merge(self::getTraitMethods($class)) 36 | ->reject(function (ReflectionMethod $method) use ($className) { 37 | return $method->class !== $className || $method->getNumberOfParameters() > 0; 38 | }) 39 | ->mapWithKeys(fn (ReflectionMethod $method) => [ 40 | $method->getName() => self::findRelations($model, $method), 41 | ]) 42 | ->filter(); 43 | } 44 | 45 | /** 46 | * @return ?Collection 47 | */ 48 | private static function findRelations(Model $model, ReflectionMethod $method): ?Collection 49 | { 50 | $attributes = self::getRelationAttributes($model, $method); 51 | 52 | return $attributes ? self::makeRelation($attributes) : null; 53 | } 54 | 55 | private static function getRelationAttributes(Model $model, ReflectionMethod $method): ?array 56 | { 57 | try { 58 | $return = $method->invoke($model); 59 | 60 | if (! $return instanceof EloquentRelation) { 61 | return null; 62 | } 63 | 64 | $type = (new ReflectionClass($return))->getName(); 65 | $related = (new ReflectionClass($return->getRelated()))->getName(); 66 | 67 | if ($return instanceof BelongsToMany) { 68 | return self::belongsToMany($return, $type, $related); 69 | } 70 | 71 | if ($return instanceof BelongsTo) { 72 | return self::belongsTo($return, $type, $related); 73 | } 74 | 75 | if ($return instanceof HasOneOrMany) { 76 | return self::hasOneOrMany($return, $type, $related); 77 | } 78 | } catch (RuntimeException|ReflectionException|Throwable $e) { 79 | // dump($method->getName()); 80 | // dump($e->getMessage()); 81 | } 82 | 83 | return null; 84 | } 85 | 86 | private static function belongsToMany(BelongsToMany $return, string $type, string $related): array 87 | { 88 | // dump([ 89 | // 'getExistenceCompareKey' => $return->getExistenceCompareKey(), 90 | // 'getForeignPivotKeyName' => $return->getForeignPivotKeyName(), 91 | // 'getQualifiedForeignPivotKeyName' => $return->getQualifiedForeignPivotKeyName(), 92 | // 'getRelatedPivotKeyName' => $return->getRelatedPivotKeyName(), 93 | // 'getQualifiedRelatedPivotKeyName' => $return->getQualifiedRelatedPivotKeyName(), 94 | // 'getParentKeyName' => $return->getParentKeyName(), 95 | // 'getQualifiedParentKeyName' => $return->getQualifiedParentKeyName(), 96 | // 'getRelatedKeyName' => $return->getRelatedKeyName(), 97 | // 'getQualifiedRelatedKeyName' => $return->getQualifiedRelatedKeyName(), 98 | // 'getRelationName' => $return->getRelationName(), 99 | // 'getPivotAccessor' => $return->getPivotAccessor(), 100 | // 'getPivotColumns' => $return->getPivotColumns(), 101 | // ]); 102 | 103 | $parent = get_class($return->getParent()); 104 | $pivot = [ 105 | 'type' => BelongsTo::class, 106 | // 'type' => $type, 107 | 'related' => $related, 108 | 'parent' => $parent, 109 | 'local_key' => $return->getQualifiedRelatedPivotKeyName(), 110 | 'foreign_key' => $return->getQualifiedRelatedKeyName(), 111 | ]; 112 | 113 | if ($return instanceof MorphToMany) { 114 | // dump([ 115 | // 'getMorphType' => $return->getMorphType(), 116 | // 'getMorphClass' => $return->getMorphClass(), 117 | // ]); 118 | 119 | $pivot = array_merge($pivot, [ 120 | 'type' => MorphTo::class, 121 | 'morph_class' => $return->getMorphClass(), 122 | 'morph_type' => $return->getMorphType(), 123 | ]); 124 | } 125 | 126 | return [ 127 | 'type' => $type, 128 | 'related' => $related, 129 | 'parent' => $parent, 130 | 'local_key' => $return->getQualifiedParentKeyName(), 131 | 'foreign_key' => $return->getQualifiedForeignPivotKeyName(), 132 | 'pivot' => $pivot, 133 | ]; 134 | } 135 | 136 | private static function belongsTo(BelongsTo $return, string $type, string $related): ?array 137 | { 138 | // dump([ 139 | // 'getForeignKeyName' => $return->getForeignKeyName(), 140 | // 'getQualifiedForeignKeyName' => $return->getQualifiedForeignKeyName(), 141 | // 'getParentKey' => $return->getParentKey(), 142 | // 'getOwnerKeyName' => $return->getOwnerKeyName(), 143 | // 'getQualifiedOwnerKeyName' => $return->getQualifiedOwnerKeyName(), 144 | // 'getRelationName' => $return->getRelationName(), 145 | // ]); 146 | 147 | if ($return instanceof MorphTo) { 148 | // dump([ 149 | // 'getMorphType' => $return->getMorphType(), 150 | // 'getDictionary' => $return->getDictionary(), 151 | // ]); 152 | return null; 153 | } 154 | 155 | return [ 156 | 'type' => $type, 157 | 'related' => $related, 158 | 'parent' => get_class($return->getParent()), 159 | 'local_key' => $return->getQualifiedForeignKeyName(), 160 | 'foreign_key' => $return->getQualifiedOwnerKeyName(), 161 | ]; 162 | } 163 | 164 | private static function hasOneOrMany(HasOneOrMany $return, string $type, string $related): ?array 165 | { 166 | if ($return instanceof HasOne && $return->isOneOfMany()) { 167 | return null; 168 | } 169 | 170 | // dump([ 171 | // 'getQualifiedParentKeyName' => $return->getQualifiedParentKeyName(), 172 | // 'getQualifiedForeignKeyName' => $return->getQualifiedForeignKeyName(), 173 | // ]); 174 | 175 | $attributes = [ 176 | 'type' => $type, 177 | 'related' => $related, 178 | 'parent' => get_class($return->getParent()), 179 | 'local_key' => $return->getQualifiedParentKeyName(), 180 | 'foreign_key' => $return->getQualifiedForeignKeyName(), 181 | ]; 182 | 183 | if ($return instanceof MorphOneOrMany) { 184 | // dump([ 185 | // 'getQualifiedMorphType' => $return->getQualifiedMorphType(), 186 | // 'getMorphClass' => $return->getMorphClass(), 187 | // ]); 188 | $attributes = array_merge($attributes, [ 189 | 'morph_type' => $return->getQualifiedMorphType(), 190 | 'morph_class' => $return->getMorphClass(), 191 | ]); 192 | } 193 | 194 | return $attributes; 195 | } 196 | 197 | /** 198 | * @param ReflectionClass $class 199 | * @return Collection 200 | */ 201 | private static function getTraitMethods(ReflectionClass $class): Collection 202 | { 203 | return collect($class->getTraits())->flatMap( 204 | static fn (ReflectionClass $trait) => $trait->getMethods(ReflectionMethod::IS_PUBLIC) 205 | ); 206 | } 207 | 208 | /** 209 | * @param string[] $attributes 210 | * @return Collection 211 | */ 212 | private static function makeRelation(array $attributes): Collection 213 | { 214 | $relation = new Relation($attributes); 215 | $relations = collect([$relation]); 216 | 217 | $pivot = $relation->pivot(); 218 | if ($pivot) { 219 | $relations->add(new Relation($pivot->toArray())); 220 | } 221 | 222 | return $relations; 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /resources/dist/panzoom.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Panzoom for panning and zooming elements using CSS transforms 3 | * Copyright Timmy Willison and other contributors 4 | * https://github.com/timmywil/panzoom/blob/main/MIT-License.txt 5 | */ 6 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Panzoom=e()}(this,function(){"use strict";var Y=function(){return(Y=Object.assign||function(t){for(var e,n=1,o=arguments.length;n