From 356fb451340d8636698a0ad0ab0a2c87a9acb195 Mon Sep 17 00:00:00 2001 From: Dan Harrin Date: Tue, 12 Aug 2025 09:06:56 +0100 Subject: [PATCH 1/8] first pass --- packages/upgrade/composer.json | 9 +- .../UpgradeDirectoryStructureToV4Command.php | 389 ++++++++++++++++++ .../upgrade/src/UpgradeServiceProvider.php | 16 + 3 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 packages/upgrade/src/Commands/UpgradeDirectoryStructureToV4Command.php create mode 100644 packages/upgrade/src/UpgradeServiceProvider.php diff --git a/packages/upgrade/composer.json b/packages/upgrade/composer.json index 6bc1fb36af..0fef7ee1f5 100644 --- a/packages/upgrade/composer.json +++ b/packages/upgrade/composer.json @@ -24,5 +24,12 @@ "sort-packages": true }, "minimum-stability": "dev", - "prefer-stable": true + "prefer-stable": true, + "extra": { + "laravel": { + "providers": [ + "Filament\\Upgrade\\UpgradeServiceProvider" + ] + } + } } diff --git a/packages/upgrade/src/Commands/UpgradeDirectoryStructureToV4Command.php b/packages/upgrade/src/Commands/UpgradeDirectoryStructureToV4Command.php new file mode 100644 index 0000000000..6d27acfd55 --- /dev/null +++ b/packages/upgrade/src/Commands/UpgradeDirectoryStructureToV4Command.php @@ -0,0 +1,389 @@ + + */ + protected function getOptions(): array + { + return [ + new InputOption( + name: 'dry-run', + shortcut: 'D', + mode: InputOption::VALUE_NONE, + description: 'Preview changes without executing them', + ), + ]; + } + + public function handle(): int + { + $isDryRun = $this->option('dry-run'); + + if ($isDryRun) { + $this->info('Running in dry-run mode. No changes will be made.'); + } + + $this->info('Starting migration from Filament v3 to v4...'); + + // Step 1: Download phpactor if it doesn't exist (skip in dry-run mode) + if (! $isDryRun) { + $this->downloadPhpactor(); + } else { + $this->info('[DRY RUN] Would download phpactor to vendor/bin/phpactor.phar'); + $this->phpactorPath = base_path('vendor/bin/phpactor.phar'); + } + + // Step 2: Get all panels + $panels = Filament::getPanels(); + $this->info('Found ' . count($panels) . ' panel(s)'); + + foreach ($panels as $panel) { + $this->info('Processing panel: ' . $panel->getId()); + + // Step 3: Get all resources for this panel + $resources = $panel->getResources(); + $this->info('Found ' . count($resources) . ' resource(s) in panel'); + + foreach ($resources as $resourceClass) { + $this->processResource($resourceClass, $isDryRun); + } + + // Step 4: Get all clusters for this panel + $clusters = $panel->getClusters(); + $this->info('Found ' . count($clusters) . ' cluster(s) in panel'); + + foreach ($clusters as $clusterClass) { + $this->processCluster($clusterClass, $isDryRun); + } + } + + if ($isDryRun) { + $this->info('Dry run completed. Run without --dry-run to apply changes.'); + } else { + $this->info('Migration completed successfully!'); + } + + return self::SUCCESS; + } + + protected function downloadPhpactor(): void + { + $this->phpactorPath = base_path('vendor/bin/phpactor.phar'); + + if (File::exists($this->phpactorPath)) { + $this->info('Phpactor already exists at: ' . $this->phpactorPath); + + return; + } + + $this->info('Downloading phpactor...'); + $process = Process::fromShellCommandline( + 'curl -Lo ' . $this->phpactorPath . ' https://github.com/phpactor/phpactor/releases/latest/download/phpactor.phar' + ); + $process->run(); + + if (! $process->isSuccessful()) { + $this->error('Failed to download phpactor: ' . $process->getErrorOutput()); + + throw new RuntimeException('Failed to download phpactor'); + } + + // Make phpactor executable + chmod($this->phpactorPath, 0755); + $this->info('Phpactor downloaded successfully to: ' . $this->phpactorPath); + } + + /** + * @param class-string $resourceClass + */ + protected function processResource(string $resourceClass, bool $isDryRun = false): void + { + $this->info('Processing resource: ' . $resourceClass); + + // Get the base path for the resource + $resourceReflection = new ReflectionClass($resourceClass); + $resourcePath = $resourceReflection->getFileName(); + + if ($resourcePath === false) { + $this->warn("Could not get file path for resource: {$resourceClass}"); + + return; + } + + // Skip if the resource is in vendor directory + if ($this->isVendorPath($resourcePath)) { + $this->warn("Skipping resource in vendor directory: {$resourcePath}"); + + return; + } + + $resourceNamespace = $resourceReflection->getNamespaceName(); + $resourceBaseName = class_basename($resourceClass); + + // Extract directory information + $resourceDir = dirname($resourcePath); + + // Determine the new directory name (pluralized) + $resourceName = str_replace('Resource', '', $resourceBaseName); + $pluralizedName = Str::plural($resourceName); + + // Create the new directory structure with the pluralized resource name + $newResourceDir = $resourceDir . '/' . $pluralizedName; + + // Skip if the new resource directory would be in vendor + if ($this->isVendorPath($newResourceDir)) { + $this->warn("Skipping resource with destination in vendor directory: {$newResourceDir}"); + + return; + } + + $this->info("Resource will be moved from {$resourceDir} to {$newResourceDir}"); + + // Find all related classes + $this->findAndMoveRelatedClasses($resourceClass, $resourceDir, $newResourceDir, $resourceBaseName, $isDryRun); + + // Move the resource itself + $newResourcePath = $newResourceDir . '/' . $resourceBaseName . '.php'; + $this->moveClass($resourcePath, $newResourcePath, $isDryRun); + } + + /** + * @param class-string $clusterClass + */ + protected function processCluster(string $clusterClass, bool $isDryRun = false): void + { + $this->info('Processing cluster: ' . $clusterClass); + + // Get the base path for the cluster + $clusterReflection = new ReflectionClass($clusterClass); + $clusterPath = $clusterReflection->getFileName(); + + if ($clusterPath === false) { + $this->warn("Could not get file path for cluster: {$clusterClass}"); + + return; + } + + // Skip if the cluster is in vendor directory + if ($this->isVendorPath($clusterPath)) { + $this->warn("Skipping cluster in vendor directory: {$clusterPath}"); + + return; + } + + $clusterNamespace = $clusterReflection->getNamespaceName(); + $clusterBaseName = class_basename($clusterClass); + + // Extract directory information + $clusterDir = dirname($clusterPath); + + // Determine if the cluster name ends with "Cluster" + $endsWithCluster = Str::endsWith($clusterBaseName, 'Cluster'); + + // Determine the new class name and directory + if ($endsWithCluster) { + // If it already ends with Cluster, keep the same name + $newClusterBaseName = $clusterBaseName; + $newClusterDir = $clusterDir . '/' . $clusterBaseName; + } else { + // If it doesn't end with Cluster, add Cluster to the end + $newClusterBaseName = $clusterBaseName . 'Cluster'; + $newClusterDir = $clusterDir . '/' . $clusterBaseName; + } + + // Skip if the new cluster directory would be in vendor + if ($this->isVendorPath($newClusterDir)) { + $this->warn("Skipping cluster with destination in vendor directory: {$newClusterDir}"); + + return; + } + + $this->info("Cluster will be moved from {$clusterDir} to {$newClusterDir}"); + + // Create the new directory if it doesn't exist + if (! $isDryRun && ! File::exists($newClusterDir)) { + File::makeDirectory($newClusterDir, 0755, true); + } elseif ($isDryRun && ! File::exists($newClusterDir)) { + $this->info("[DRY RUN] Would create directory: {$newClusterDir}"); + } + + // Move the cluster class + $newClusterPath = $newClusterDir . '/' . $newClusterBaseName . '.php'; + $this->moveClass($clusterPath, $newClusterPath, $isDryRun); + + // Note: We don't process any other files for clusters as they are already in the correct place + $this->info('Cluster processed successfully. No other files were moved as they are already in the correct place.'); + } + + protected function findAndMoveRelatedClasses(string $resourceClass, string $resourceDir, string $newResourceDir, string $resourceBaseName, bool $isDryRun = false): void + { + // Find all PHP files in the resource directory and subdirectories + $files = $this->findPhpFiles($resourceDir); + + $this->info('Found ' . count($files) . ' file(s) in resource directory'); + + $relatedFiles = []; + foreach ($files as $file) { + // Skip the resource file itself, we'll handle it separately + if (basename($file) === $resourceBaseName . '.php') { + continue; + } + + // Only process files that are related to this resource + // Check if the file path contains the resource basename directory + if (strpos($file, $resourceDir . '/' . $resourceBaseName) === false) { + $this->info("Skipping file not related to {$resourceBaseName}: {$file}"); + + continue; + } + + $relatedFiles[] = $file; + } + + $this->info('Found ' . count($relatedFiles) . ' related file(s) for resource ' . $resourceBaseName); + + foreach ($relatedFiles as $file) { + // Determine the new path for this file + $relativePath = str_replace($resourceDir, '', $file); + + // Check if the relative path starts with /{ResourceBaseName}/ + $resourceDirPattern = '/' . $resourceBaseName . '/'; + if (strpos($relativePath, $resourceDirPattern) === 0) { + // Remove the resource basename directory from the path + $relativePath = substr($relativePath, strlen($resourceDirPattern)); + // Add a slash before the remaining path + $relativePath = '/' . $relativePath; + } + + $newPath = $newResourceDir . $relativePath; + + // Ensure the directory exists + $newDir = dirname($newPath); + if (! $isDryRun && ! File::exists($newDir)) { + File::makeDirectory($newDir, 0755, true); + } elseif ($isDryRun && ! File::exists($newDir)) { + $this->info("[DRY RUN] Would create directory: {$newDir}"); + } + + // Move the class + $this->moveClass($file, $newPath, $isDryRun); + } + } + + /** + * Check if a path is in the vendor directory + */ + protected function isVendorPath(string $path): bool + { + return str_contains($path, '/vendor/'); + } + + /** + * Find all PHP files in a directory + * + * @param string $directory The directory to search in + * @return array Array of file paths + */ + protected function findPhpFiles(string $directory): array + { + $files = []; + + if (! File::exists($directory)) { + return $files; + } + + // Skip if the directory is in vendor + if ($this->isVendorPath($directory)) { + $this->warn("Skipping vendor directory: {$directory}"); + + return $files; + } + + $items = File::allFiles($directory); + + foreach ($items as $item) { + $pathname = $item->getPathname(); + + // Skip files in vendor directory + if ($this->isVendorPath($pathname)) { + $this->info("Skipping vendor file: {$pathname}"); + + continue; + } + + if ($item->getExtension() === 'php') { + $files[] = $pathname; + } + } + + return $files; + } + + protected function moveClass(string $sourcePath, string $destinationPath, bool $isDryRun = false): void + { + // Safety check: Never touch files in the vendor directory + if ($this->isVendorPath($sourcePath)) { + $this->warn("Skipping file in vendor directory: {$sourcePath}"); + + return; + } + + if ($this->isVendorPath($destinationPath)) { + $this->warn("Skipping move to vendor directory: {$destinationPath}"); + + return; + } + + // Ensure the destination directory exists + $destinationDir = dirname($destinationPath); + if (! $isDryRun && ! File::exists($destinationDir)) { + File::makeDirectory($destinationDir, 0755, true); + } elseif ($isDryRun && ! File::exists($destinationDir)) { + $this->info("[DRY RUN] Would create directory: {$destinationDir}"); + } + + if ($isDryRun) { + $this->info("[DRY RUN] Would move class from {$sourcePath} to {$destinationPath}"); + + return; + } + + $this->info("Moving class from {$sourcePath} to {$destinationPath}"); + + try { + // Use phpactor to move the class + $process = Process::fromShellCommandline( + "php {$this->phpactorPath} class:move {$sourcePath} {$destinationPath}" + ); + $process->setTimeout(60); // Give it a minute to complete + $process->run(); + + if ($process->isSuccessful()) { + $this->info("Successfully moved class from {$sourcePath} to {$destinationPath}"); + } else { + $this->error('Failed to move class: ' . $process->getErrorOutput()); + } + } catch (Exception $exception) { + $this->error('Exception occurred while moving class: ' . $exception->getMessage()); + } + } +} diff --git a/packages/upgrade/src/UpgradeServiceProvider.php b/packages/upgrade/src/UpgradeServiceProvider.php new file mode 100644 index 0000000000..126fcb9066 --- /dev/null +++ b/packages/upgrade/src/UpgradeServiceProvider.php @@ -0,0 +1,16 @@ +commands([ + UpgradeDirectoryStructureToV4Command::class, + ]); + } +} From 94e031ad5f2c272feb1eeb3286126d2e40afebef Mon Sep 17 00:00:00 2001 From: Dan Harrin Date: Tue, 12 Aug 2025 09:43:17 +0100 Subject: [PATCH 2/8] clean up --- docs/01-introduction/02-installation.md | 28 --- docs/14-upgrade-guide.md | 54 +++-- .../UpgradeDirectoryStructureToV4Command.php | 220 +++++++++--------- 3 files changed, 151 insertions(+), 151 deletions(-) diff --git a/docs/01-introduction/02-installation.md b/docs/01-introduction/02-installation.md index 77f11b059b..4caddec855 100644 --- a/docs/01-introduction/02-installation.md +++ b/docs/01-introduction/02-installation.md @@ -38,20 +38,6 @@ Installation comes in two flavors, depending on whether you want to build an app ## Installing the panel builder -Since Filament v4 is in beta, you will need to set the `minimum-stability` in your `composer.json` file to be `beta` before installing any packages. Either adjust it manually or via CLI: - -```bash -composer config minimum-stability beta -``` - -Your `composer.json` should look like this: - -```json -{ - "minimum-stability": "beta" -} -``` - Install the Filament Panel Builder by running the following commands in your Laravel project directory: ```bash @@ -94,20 +80,6 @@ Open `/admin` in your web browser, sign in, and [start building your app](../get ## Installing the individual components -Since Filament v4 is in beta, you will need to set the `minimum-stability` in your `composer.json` file to be `beta` before installing any packages. Either adjust it manually or via CLI: - -```bash -composer config minimum-stability beta -``` - -Your `composer.json` should look like this: - -```json -{ - "minimum-stability": "beta" -} -``` - Install the Filament components you want to use with Composer: ```bash diff --git a/docs/14-upgrade-guide.md b/docs/14-upgrade-guide.md index fd9c34bbcb..4b56088fdc 100644 --- a/docs/14-upgrade-guide.md +++ b/docs/14-upgrade-guide.md @@ -23,26 +23,20 @@ import Disclosure from "@components/Disclosure.astro" The upgrade script is not a replacement for the upgrade guide. It handles many small changes that are not mentioned in the upgrade guide, but it does not handle all breaking changes. You should still read the [manual upgrade steps](#breaking-changes-that-must-be-handled-manually) to see what changes you need to make to your code. -The first step to upgrade your Filament app is to run the automated upgrade script. Since Filament v4 is in beta, you will need to set the `minimum-stability` in your `composer.json` file to be `beta` before installing any packages. Either adjust it manually or via CLI: + -```bash -composer config minimum-stability beta -``` - -Your `composer.json` should look like this: - -```json -{ - "minimum-stability": "beta" -} -``` - -This script will automatically upgrade your application to the latest version of Filament and make changes to your code, which handles most breaking changes: +The first step to upgrade your Filament app is to run the automated upgrade script. This script will automatically upgrade your application to the latest version of Filament and make changes to your code, which handles most breaking changes: ```bash composer require filament/upgrade:"^4.0" -W --dev vendor/bin/filament-v4 + +# Run the commands output by the upgrade script, they are unique to your app +composer require filament/filament:"^4.0" -W --no-update +composer update ``` @@ -61,12 +59,18 @@ vendor/bin/filament-v4 Make sure to carefully follow the instructions, and review the changes made by the script. You may need to make some manual changes to your code afterwards, but the script should handle most of the repetitive work for you. -You can now `composer remove filament/upgrade` as you don't need it anymore. +Filament v4 introduces a new default directory structure for your Filament resources and clusters. If you are using Filament panels with resources and clusters, you can choose to keep the old directory structure, or migrate to the new one. If you want to migrate to the new directory structure, you can run the following command: -