- Some plugins you're using may not be available in v4 just yet. You could temporarily remove them from your `composer.json` file until they've been upgraded, replace them with a similar plugins that are v4-compatible, wait for the plugins to be upgraded before upgrading your app, or even write PRs to help the authors upgrade them.
+```bash
+php artisan filament:upgrade-directory-structure-to-v4 --dry-run
+```
+
+The `--dry-run` option will show you what the command would do without actually making any changes. If you are happy with the changes, you can run the command without the `--dry-run` option to apply the changes:
+
+```bash
+php artisan filament:upgrade-directory-structure-to-v4
+```
+
+
+ This directory upgrade script is not able to perfectly update any references to classes in the same namespace that were present in resource and cluster files, and those references will need to be updated manually after the script has run. You should use tools like [PHPStan](https://phpstan.org) to identify references to classes that are broken after the upgrade.
+You can now `composer remove filament/upgrade` as you don't need it anymore.
+
## Publishing the configuration file
Some changes in Filament v4 can be reverted using the configuration file. If you haven't published the configuration file yet, you can do so by running the following command:
@@ -102,8 +112,8 @@ return [
'flags' => [
FileGenerationFlag::EMBEDDED_PANEL_RESOURCE_SCHEMAS, // Define new forms and infolists inside the resource class instead of a separate schema class.
FileGenerationFlag::EMBEDDED_PANEL_RESOURCE_TABLES, // Define new tables inside the resource class instead of a separate table class.
- FileGenerationFlag::PANEL_CLUSTER_CLASSES_OUTSIDE_DIRECTORIES, // Create new cluster classes outside of their directories.
- FileGenerationFlag::PANEL_RESOURCE_CLASSES_OUTSIDE_DIRECTORIES, // Create new resource classes outside of their directories.
+ FileGenerationFlag::PANEL_CLUSTER_CLASSES_OUTSIDE_DIRECTORIES, // Create new cluster classes outside of their directories. Not required if you run `php artisan filament:upgrade-directory-structure-to-v4`.
+ FileGenerationFlag::PANEL_RESOURCE_CLASSES_OUTSIDE_DIRECTORIES, // Create new resource classes outside of their directories. Not required if you run `php artisan filament:upgrade-directory-structure-to-v4`.
FileGenerationFlag::PARTIAL_IMPORTS, // Partially import components such as form fields and table columns instead of importing each component explicitly.
],
],
@@ -113,6 +123,24 @@ return [
]
```
+
+ The `filament/upgrade` package includes a command to help you move panel resources and clusters to the new directory structure, which is the default in v4:
+
+ ```bash
+ php artisan filament:upgrade-directory-structure-to-v4 --dry-run
+ ```
+
+ The `--dry-run` option will show you what the command would do without actually making any changes. If you are happy with the changes, you can run the command without the `--dry-run` option to apply the changes:
+
+ ```bash
+ php artisan filament:upgrade-directory-structure-to-v4
+ ```
+
+ This directory upgrade script is not able to perfectly update any references to classes in the same namespace that were present in resource and cluster files, and those references will need to be updated manually after the script has run. You should use tools like [PHPStan](https://phpstan.org) to identify references to classes that are broken after the upgrade.
+
+ Once you have run the command, you do not need to keep the `FileGenerationFlag::PANEL_CLUSTER_CLASSES_OUTSIDE_DIRECTORIES` or `FileGenerationFlag::PANEL_RESOURCE_CLASSES_OUTSIDE_DIRECTORIES` flags in your configuration file, as the new directory structure is now the default. You can remove them from the `file_generation.flags` array.
+
+
## Breaking changes that must be handled manually
diff --git a/packages/upgrade/composer.json b/packages/upgrade/composer.json
index 6bc1fb36af..9250decc7a 100644
--- a/packages/upgrade/composer.json
+++ b/packages/upgrade/composer.json
@@ -17,6 +17,13 @@
"Filament\\Upgrade\\": "src"
}
},
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Filament\\Upgrade\\UpgradeServiceProvider"
+ ]
+ }
+ },
"bin": [
"bin/filament-v4"
],
diff --git a/packages/upgrade/src/Commands/UpgradeDirectoryStructureToV4Command.php b/packages/upgrade/src/Commands/UpgradeDirectoryStructureToV4Command.php
new file mode 100644
index 0000000000..420519fcb0
--- /dev/null
+++ b/packages/upgrade/src/Commands/UpgradeDirectoryStructureToV4Command.php
@@ -0,0 +1,361 @@
+>
+ */
+ protected array $movedFiles = [];
+
+ protected ?string $currentResource = null;
+
+ /**
+ * @return array
+ */
+ protected function getOptions(): array
+ {
+ return [
+ new InputOption(
+ name: 'dry-run',
+ shortcut: 'D',
+ mode: InputOption::VALUE_NONE,
+ description: 'Preview changes without executing them',
+ ),
+ ];
+ }
+
+ protected function formatPath(string $path): string
+ {
+ return str_replace(base_path() . DIRECTORY_SEPARATOR, '', $path);
+ }
+
+ public function handle(): int
+ {
+ $isDryRun = $this->option('dry-run');
+
+ if (! $isDryRun && ! $this->components->confirm('This command will modify your Filament resources and clusters to match the new v4 directory structure. Please commit any changes you have made to your project before continuing. Do you want to continue?', default: true)) {
+ $this->components->info('Migration cancelled.');
+
+ return self::FAILURE;
+ }
+
+ $this->components->info('Starting migration from Filament v3 to v4...');
+
+ if ($isDryRun) {
+ $this->newLine();
+ $this->components->info('Running in dry-run mode. No changes will be made.');
+ $this->newLine();
+ }
+
+ if (! $isDryRun) {
+ $this->downloadPhpactor();
+ } else {
+ $this->phpactorPath = base_path('vendor' . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'phpactor.phar');
+ }
+
+ $panels = Filament::getPanels();
+ foreach ($panels as $panel) {
+ $resources = $panel->getResources();
+
+ if (count($resources) > 0) {
+ $this->components->info('Processing resources in ' . $panel->getId() . ' panel');
+
+ foreach ($resources as $resourceClass) {
+ $this->processResource($resourceClass, $isDryRun);
+ }
+
+ $this->newLine();
+ }
+
+ $clusters = $panel->getClusters();
+
+ if (count($clusters) > 0) {
+ $this->components->info('Processing resources in ' . $panel->getId() . ' panel');
+
+ foreach ($clusters as $clusterClass) {
+ $this->processCluster($clusterClass, $isDryRun);
+ }
+
+ $this->newLine();
+ }
+ }
+
+ if ($isDryRun) {
+ $this->components->info('Dry run completed. Run without --dry-run to apply changes.');
+ } else {
+ $this->components->info('Migration completed successfully!');
+ }
+
+ return self::SUCCESS;
+ }
+
+ protected function downloadPhpactor(): void
+ {
+ $this->phpactorPath = base_path('vendor' . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'phpactor.phar');
+
+ if (File::exists($this->phpactorPath)) {
+ $this->components->info('Phpactor already exists at: ' . $this->formatPath($this->phpactorPath));
+
+ return;
+ }
+
+ $this->components->task('Downloading phpactor', function () {
+ $process = Process::command(
+ 'curl -Lo ' . $this->phpactorPath . ' https://github.com/phpactor/phpactor/releases/latest/download/phpactor.phar'
+ );
+ $processOutput = $process->run();
+
+ if (! $processOutput->successful()) {
+ $this->error('Failed to download phpactor: ' . $processOutput->errorOutput());
+
+ throw new RuntimeException('Failed to download phpactor');
+ }
+
+ chmod($this->phpactorPath, 0755);
+
+ return true;
+ });
+ }
+
+ /**
+ * @param class-string $resourceClass
+ */
+ protected function processResource(string $resourceClass, bool $isDryRun = false): void
+ {
+ $resourceReflection = new ReflectionClass($resourceClass);
+ $resourcePath = $resourceReflection->getFileName();
+
+ if ($resourcePath === false) {
+ $this->components->warn("Could not get file path for resource: {$resourceClass}");
+
+ return;
+ }
+
+ if ($this->isVendorPath($resourcePath)) {
+ $this->components->warn('Skipping resource in vendor directory');
+
+ return;
+ }
+
+ $resourceBaseName = class_basename($resourceClass);
+ $resourceDirectory = dirname($resourcePath);
+
+ $this->currentResource = $resourceBaseName;
+
+ $resourceName = Str::replaceEnd('Resource', '', $resourceBaseName);
+ $pluralizedName = Str::plural($resourceName);
+
+ $newResourceDirectory = $resourceDirectory . DIRECTORY_SEPARATOR . $pluralizedName;
+
+ if ($this->isVendorPath($newResourceDirectory)) {
+ $this->components->warn('Skipping resource with destination in vendor directory');
+
+ return;
+ }
+
+ $this->findAndMoveRelatedClasses($resourceClass, $resourceDirectory, $newResourceDirectory, $resourceBaseName, $isDryRun);
+
+ $newResourcePath = $newResourceDirectory . DIRECTORY_SEPARATOR . $resourceBaseName . '.php';
+ $this->moveClass($resourcePath, $newResourcePath, $isDryRun);
+ }
+
+ /**
+ * @param class-string $clusterClass
+ */
+ protected function processCluster(string $clusterClass, bool $isDryRun = false): void
+ {
+ $clusterReflection = new ReflectionClass($clusterClass);
+ $clusterPath = $clusterReflection->getFileName();
+
+ if ($clusterPath === false) {
+ $this->components->warn("Could not get file path for cluster: {$clusterClass}");
+
+ return;
+ }
+
+ if ($this->isVendorPath($clusterPath)) {
+ $this->components->warn('Skipping cluster in vendor directory');
+
+ return;
+ }
+
+ $clusterBaseName = class_basename($clusterClass);
+ $clusterDirectory = dirname($clusterPath);
+
+ $this->currentResource = $clusterBaseName;
+
+ $endsWithCluster = Str::endsWith($clusterBaseName, 'Cluster');
+
+ if ($endsWithCluster) {
+ $newClusterBaseName = $clusterBaseName;
+ $newClusterDirectory = $clusterDirectory . DIRECTORY_SEPARATOR . $clusterBaseName;
+ } else {
+ $newClusterBaseName = $clusterBaseName . 'Cluster';
+ $newClusterDirectory = $clusterDirectory . DIRECTORY_SEPARATOR . $clusterBaseName;
+ }
+
+ if ($this->isVendorPath($newClusterDirectory)) {
+ $this->components->warn('Skipping cluster with destination in vendor directory');
+
+ return;
+ }
+
+ $newClusterPath = $newClusterDirectory . DIRECTORY_SEPARATOR . $newClusterBaseName . '.php';
+ $this->moveClass($clusterPath, $newClusterPath, $isDryRun);
+ }
+
+ protected function findAndMoveRelatedClasses(string $resourceClass, string $resourceDirectory, string $newResourceDirectory, string $resourceBaseName, bool $isDryRun = false): void
+ {
+ $files = $this->findPhpFiles($resourceDirectory);
+
+ $relatedFiles = [];
+ foreach ($files as $file) {
+ if (basename($file) === $resourceBaseName . '.php') {
+ continue;
+ }
+
+ if (strpos($file, $resourceDirectory . DIRECTORY_SEPARATOR . $resourceBaseName) === false) {
+ continue;
+ }
+
+ $relatedFiles[] = $file;
+ }
+
+ foreach ($relatedFiles as $file) {
+ $relativePath = str_replace($resourceDirectory, '', $file);
+
+ $resourceDirectoryPattern = DIRECTORY_SEPARATOR . $resourceBaseName . DIRECTORY_SEPARATOR;
+ if (strpos($relativePath, $resourceDirectoryPattern) === 0) {
+ $relativePath = substr($relativePath, strlen($resourceDirectoryPattern));
+ $relativePath = DIRECTORY_SEPARATOR . $relativePath;
+ }
+
+ $newPath = $newResourceDirectory . $relativePath;
+
+ $this->moveClass($file, $newPath, $isDryRun);
+ }
+ }
+
+ protected function isVendorPath(string $path): bool
+ {
+ return str_contains($path, DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR);
+ }
+
+ /**
+ * @param string $directory
+ * @return array
+ */
+ protected function findPhpFiles(string $directory): array
+ {
+ $files = [];
+
+ if (! File::exists($directory)) {
+ return $files;
+ }
+
+ if ($this->isVendorPath($directory)) {
+ return $files;
+ }
+
+ $items = File::allFiles($directory);
+
+ foreach ($items as $item) {
+ $pathname = $item->getPathname();
+
+ if ($this->isVendorPath($pathname)) {
+ continue;
+ }
+
+ if ($item->getExtension() === 'php') {
+ $files[] = $pathname;
+ }
+ }
+
+ return $files;
+ }
+
+ protected function moveClass(string $sourcePath, string $destinationPath, bool $isDryRun = false): void
+ {
+ if ($this->isVendorPath($sourcePath)) {
+ $this->components->warn('Skipping file in vendor directory');
+
+ return;
+ }
+
+ if ($this->isVendorPath($destinationPath)) {
+ $this->components->warn('Skipping move to vendor directory');
+
+ return;
+ }
+
+ if ($isDryRun) {
+ if ($this->currentResource && (! isset($this->movedFiles[$this->currentResource]) || empty($this->movedFiles[$this->currentResource]))) {
+ $this->line(' ' . $this->currentResource . '>');
+ $this->movedFiles[$this->currentResource] = [];
+ } elseif (! $this->currentResource && (! isset($this->movedFiles['Other']) || empty($this->movedFiles['Other']))) {
+ $this->line(' Other>');
+ $this->movedFiles['Other'] = [];
+ }
+
+ $this->line(' • ' . $this->formatPath($sourcePath) . ' → ' . $this->formatPath($destinationPath));
+
+ if ($this->currentResource) {
+ $this->movedFiles[$this->currentResource][$sourcePath] = $destinationPath;
+ } else {
+ $this->movedFiles['Other'][$sourcePath] = $destinationPath;
+ }
+
+ return;
+ }
+
+ try {
+ $process = Process::command(
+ "php {$this->phpactorPath} class:move {$sourcePath} {$destinationPath}"
+ );
+ $process->timeout(60);
+ $processOutput = $process->run();
+
+ if ($processOutput->successful()) {
+ if ($this->currentResource && (! isset($this->movedFiles[$this->currentResource]) || empty($this->movedFiles[$this->currentResource]))) {
+ $this->line(' ' . $this->currentResource . '>');
+ $this->movedFiles[$this->currentResource] = [];
+ } elseif (! $this->currentResource && (! isset($this->movedFiles['Other']) || empty($this->movedFiles['Other']))) {
+ $this->line(' Other>');
+ $this->movedFiles['Other'] = [];
+ }
+
+ $this->line(' • ' . $this->formatPath($sourcePath) . ' → ' . $this->formatPath($destinationPath));
+
+ if ($this->currentResource) {
+ $this->movedFiles[$this->currentResource][$sourcePath] = $destinationPath;
+ } else {
+ $this->movedFiles['Other'][$sourcePath] = $destinationPath;
+ }
+ } else {
+ $this->components->error('Failed to move class: ' . $processOutput->errorOutput());
+ }
+ } catch (Exception $exception) {
+ $this->components->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,
+ ]);
+ }
+}
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index 5b9c5b0c9b..4e38f72f7d 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -5,7 +5,8 @@ parameters:
- packages
excludePaths:
- - packages/upgrade/*
+ - packages/upgrade/bin
+ - packages/upgrade/src/Rector
reportUnmatchedIgnoredErrors: false