├── stubs └── .gitkeep ├── resources ├── dist │ └── .gitkeep ├── js │ └── index.js ├── css │ └── index.css └── lang │ └── en │ └── modules.php ├── src ├── Commands │ ├── stubs │ │ ├── filament-theme-css.stub │ │ ├── filament-theme-postcss.stub │ │ ├── filament-theme-tailwind-config.stub │ │ ├── filament-plugin.stub │ │ └── filament-cluster.stub │ ├── ModuleMakeFilamentPluginCommand.php │ ├── ModuleMakeFilamentClusterCommand.php │ ├── ModuleMakeFilamentThemeCommand.php │ ├── ModuleFilamentInstallCommand.php │ ├── ModuleMakeFilamentPanelCommand.php │ ├── ModuleMakeFilamentResourceCommand.php │ ├── ModuleMakeFilamentWidgetCommand.php │ ├── FileGenerators │ │ └── ModulePanelProviderClassGenerator.php │ └── ModuleMakeFilamentPageCommand.php ├── Page.php ├── Resource.php ├── Testing │ └── TestsModules.php ├── ChartWidget.php ├── TableWidget.php ├── StatsOverviewWidget.php ├── Facades │ └── FilamentModules.php ├── Enums │ └── ConfigMode.php ├── Traits │ └── CanAccessTrait.php ├── Concerns │ ├── ModuleFilamentPlugin.php │ ├── CanManipulateFiles.php │ ├── CanGenerateModulePanels.php │ └── GeneratesModularFiles.php ├── ModulesPlugin.php ├── Modules.php └── ModulesServiceProvider.php ├── postcss.config.cjs ├── config └── filament-modules.php ├── LICENSE.md ├── bin └── build.js ├── composer.json ├── README.md └── CHANGELOG.md /stubs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/dist/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/js/index.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/css/index.css: -------------------------------------------------------------------------------- 1 | @import '../../vendor/filament/filament/resources/css/theme.css'; 2 | -------------------------------------------------------------------------------- /resources/lang/en/modules.php: -------------------------------------------------------------------------------- 1 | value, [self::PANELS->value, self::BOTH->value]); 15 | } 16 | 17 | public function shouldRegisterPlugins(): bool 18 | { 19 | return in_array($this->value, [self::PLUGINS->value, self::BOTH->value]); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Commands/stubs/filament-plugin.stub: -------------------------------------------------------------------------------- 1 | isEnabled(); 21 | $parentAccess = function_exists('canAccess') ? parent::canAccess() : true; 22 | 23 | if ($isModuleEnabled && $parentAccess) { 24 | return true; 25 | } 26 | 27 | return false; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /config/filament-modules.php: -------------------------------------------------------------------------------- 1 | \Coolsam\Modules\Enums\ConfigMode::BOTH->value, // 'plugins' or 'panels', determines how the Filament Modules are registered 6 | 'auto-register-plugins' => true, // whether to auto-register plugins from various modules in the Panel. Only relevant if 'mode' is set to 'plugins'. 7 | 'clusters' => [ 8 | 'enabled' => true, // whether to enable the clusters feature which allows you to group each module's filament resources and pages into a cluster 9 | 'use-top-navigation' => true, // display the main cluster menu in the top navigation and the sub-navigation in the side menu, which improves the UI 10 | ], 11 | 'panels' => [ 12 | 'group' => 'Panels', // the group name for the panels in the navigation 13 | 'group-icon' => \Filament\Support\Icons\Heroicon::OutlinedRectangleStack, 14 | 'group-sort' => 0, // the sort order of the panels group in the navigation 15 | 'open-in-new-tab' => false, // whether to open the panels in a new tab 16 | ], 17 | ]; 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) coolsam 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /bin/build.js: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild' 2 | 3 | const isDev = process.argv.includes('--dev') 4 | 5 | async function compile(options) { 6 | const context = await esbuild.context(options) 7 | 8 | if (isDev) { 9 | await context.watch() 10 | } else { 11 | await context.rebuild() 12 | await context.dispose() 13 | } 14 | } 15 | 16 | const defaultOptions = { 17 | define: { 18 | 'process.env.NODE_ENV': isDev ? `'development'` : `'production'`, 19 | }, 20 | bundle: true, 21 | mainFields: ['module', 'main'], 22 | platform: 'neutral', 23 | sourcemap: isDev ? 'inline' : false, 24 | sourcesContent: isDev, 25 | treeShaking: true, 26 | target: ['es2020'], 27 | minify: !isDev, 28 | plugins: [{ 29 | name: 'watchPlugin', 30 | setup: function (build) { 31 | build.onStart(() => { 32 | console.log(`Build started at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`) 33 | }) 34 | 35 | build.onEnd((result) => { 36 | if (result.errors.length > 0) { 37 | console.log(`Build failed at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`, result.errors) 38 | } else { 39 | console.log(`Build finished at ${new Date(Date.now()).toLocaleTimeString()}: ${build.initialOptions.outfile}`) 40 | } 41 | }) 42 | } 43 | }], 44 | } 45 | 46 | compile({ 47 | ...defaultOptions, 48 | entryPoints: ['./resources/js/index.js'], 49 | outfile: './resources/dist/modules.js', 50 | }) 51 | -------------------------------------------------------------------------------- /src/Commands/ModuleMakeFilamentPluginCommand.php: -------------------------------------------------------------------------------- 1 | resolveStubPath('/stubs/filament-plugin.stub'); 33 | } 34 | 35 | protected function stubReplacements(): array 36 | { 37 | return [ 38 | 'moduleStudlyName' => $this->getModule()->getStudlyName(), 39 | 'pluginId' => str($this->argument('name'))->replace('Plugin', '')->studly()->lower()->toString(), 40 | ]; 41 | } 42 | 43 | public function handle(): ?bool 44 | { 45 | $this->ensureModule(); 46 | 47 | return parent::handle(); 48 | } 49 | 50 | public function ensureModule() 51 | { 52 | if (! $this->argument('module')) { 53 | $module = select('Please select the module to create the plugin in:', \Nwidart\Modules\Facades\Module::allEnabled()); 54 | $this->input->setArgument('module', $module); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Concerns/ModuleFilamentPlugin.php: -------------------------------------------------------------------------------- 1 | getModuleName()); 15 | } 16 | 17 | public function register(Panel $panel): void 18 | { 19 | $module = $this->getModule(); 20 | 21 | if (! $module->isEnabled()) { 22 | return; 23 | } 24 | 25 | $useClusters = config('filament-modules.clusters.enabled', false); 26 | $panel->discoverPages( 27 | in: $module->appPath('Filament' . DIRECTORY_SEPARATOR . 'Pages'), 28 | for: $module->appNamespace('\\Filament\\Pages') 29 | ); 30 | $panel->discoverResources( 31 | in: $module->appPath('Filament' . DIRECTORY_SEPARATOR . 'Resources'), 32 | for: $module->appNamespace('\\Filament\\Resources') 33 | ); 34 | $panel->discoverWidgets( 35 | in: $module->appPath('Filament' . DIRECTORY_SEPARATOR . 'Widgets'), 36 | for: $module->appNamespace('\\Filament\\Widgets') 37 | ); 38 | 39 | $panel->discoverLivewireComponents( 40 | in: $module->appPath('Livewire'), 41 | for: $module->appNamespace('\\Livewire') 42 | ); 43 | 44 | if ($useClusters) { 45 | $path = $module->appPath('Filament' . DIRECTORY_SEPARATOR . 'Clusters'); 46 | $namespace = $module->appNamespace('\\Filament\\Clusters'); 47 | $panel->discoverClusters( 48 | in: $path, 49 | for: $namespace, 50 | ); 51 | } 52 | $this->afterRegister($panel); 53 | } 54 | 55 | public static function make(): static 56 | { 57 | return app(static::class); 58 | } 59 | 60 | public static function get(): static 61 | { 62 | /** @var static $plugin */ 63 | $plugin = filament(app(static::class)->getId()); 64 | 65 | return $plugin; 66 | } 67 | 68 | public function afterRegister(Panel $panel) 69 | { 70 | // override this to implement additional logic 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coolsam/modules", 3 | "description": "Organize your Filament Code into modules using nwidart/laravel-modules", 4 | "keywords": [ 5 | "coolsam", 6 | "laravel", 7 | "FilamentModules", 8 | "filament" 9 | ], 10 | "homepage": "https://github.com/savannabits/filament-modules", 11 | "support": { 12 | "issues": "https://github.com/savannabits/filament-modules/issues", 13 | "source": "https://github.com/savannabits/filament-modules" 14 | }, 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "Sam Maosa", 19 | "email": "maosa.sam@gmail.com", 20 | "role": "Developer" 21 | } 22 | ], 23 | "require": { 24 | "php": "^8.2", 25 | "filament/filament": "^4.0", 26 | "nwidart/laravel-modules": "^11.0|^12.0", 27 | "spatie/laravel-package-tools": "^1.15.0" 28 | }, 29 | "require-dev": { 30 | "barryvdh/laravel-ide-helper": "^3.5", 31 | "laravel/pint": "^1.0", 32 | "nunomaduro/larastan": "^3.1.0", 33 | "orchestra/testbench": "^9.12", 34 | "pestphp/pest-plugin-laravel": "^3.1", 35 | "pestphp/pest-plugin-livewire": "^3.0", 36 | "phpstan/extension-installer": "^1.4.3", 37 | "spatie/laravel-ray": "^1.39" 38 | }, 39 | "autoload": { 40 | "psr-4": { 41 | "Coolsam\\Modules\\": "src/" 42 | } 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "Coolsam\\Modules\\Tests\\": "tests/" 47 | } 48 | }, 49 | "scripts": { 50 | "post-autoload-dump": [ 51 | "@clear", 52 | "@prepare", 53 | "@php ./vendor/bin/testbench package:discover --ansi" 54 | ], 55 | "analyse": "vendor/bin/phpstan analyse", 56 | "test": "vendor/bin/pest", 57 | "test-coverage": "vendor/bin/pest --coverage", 58 | "format": "vendor/bin/pint", 59 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", 60 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 61 | "build": "@php vendor/bin/testbench workbench:build --ansi", 62 | "serve": [ 63 | "Composer\\Config::disableProcessTimeout", 64 | "@build", 65 | "@php vendor/bin/testbench serve" 66 | ], 67 | "lint": [ 68 | "@php vendor/bin/pint", 69 | "@php vendor/bin/phpstan analyse" 70 | ] 71 | }, 72 | "config": { 73 | "sort-packages": true, 74 | "allow-plugins": { 75 | "pestphp/pest-plugin": true, 76 | "phpstan/extension-installer": true, 77 | "wikimedia/composer-merge-plugin": true 78 | } 79 | }, 80 | "extra": { 81 | "laravel": { 82 | "providers": [ 83 | "Coolsam\\Modules\\ModulesServiceProvider" 84 | ], 85 | "aliases": { 86 | "FilamentModules": "FilamentModules" 87 | } 88 | } 89 | }, 90 | "minimum-stability": "dev", 91 | "prefer-stable": true 92 | } 93 | -------------------------------------------------------------------------------- /src/Concerns/CanManipulateFiles.php: -------------------------------------------------------------------------------- 1 | $paths 18 | */ 19 | protected function checkForCollision(array $paths): bool 20 | { 21 | foreach ($paths as $path) { 22 | if (! $this->fileExists($path)) { 23 | continue; 24 | } 25 | 26 | if (! confirm(basename($path) . ' already exists, do you want to overwrite it?')) { 27 | $this->components->error("{$path} already exists, aborting."); 28 | 29 | return true; 30 | } 31 | 32 | unlink($path); 33 | } 34 | 35 | return false; 36 | } 37 | 38 | /** 39 | * @param array $replacements 40 | * 41 | * @throws FileNotFoundException 42 | * @throws ContainerExceptionInterface 43 | * @throws NotFoundExceptionInterface 44 | */ 45 | protected function copyStubToApp(string $stub, string $targetPath, array $replacements = []): void 46 | { 47 | $D = DIRECTORY_SEPARATOR; 48 | $filesystem = app(Filesystem::class); 49 | 50 | $stubPath = $this->getDefaultStubPath() . "{$D}{$stub}.stub"; 51 | 52 | $stub = str($filesystem->get($stubPath)); 53 | 54 | foreach ($replacements as $key => $replacement) { 55 | $stub = $stub->replace("{{ {$key} }}", $replacement); 56 | } 57 | 58 | $stub = (string) $stub; 59 | 60 | $this->writeFile($targetPath, $stub); 61 | } 62 | 63 | protected function fileExists(string $path): bool 64 | { 65 | $filesystem = app(Filesystem::class); 66 | 67 | return $filesystem->exists($path); 68 | } 69 | 70 | protected function writeFile(string $path, string $contents): void 71 | { 72 | $filesystem = app(Filesystem::class); 73 | 74 | $filesystem->ensureDirectoryExists( 75 | pathinfo($path, PATHINFO_DIRNAME), 76 | ); 77 | 78 | $filesystem->put($path, $contents); 79 | } 80 | 81 | protected function getDefaultStubPath(): string 82 | { 83 | return $this->getModule()->appPath('Commands' . DIRECTORY_SEPARATOR . 'stubs'); 84 | } 85 | 86 | protected function getModule(): Module 87 | { 88 | return FilamentModules::getModule($this->getModuleStudlyName()); 89 | } 90 | 91 | abstract protected function getModuleStudlyName(): string; 92 | } 93 | -------------------------------------------------------------------------------- /src/Concerns/CanGenerateModulePanels.php: -------------------------------------------------------------------------------- 1 | match (true) { 26 | preg_match('/^[a-zA-Z].*/', $value) !== false => null, 27 | default => 'The ID must start with a letter, and not a number or special character.', 28 | }, 29 | hint: 'It must be unique to any others you have, and is used to reference the panel in your code.', 30 | )); 31 | 32 | $basename = (string) str($id) 33 | ->studly() 34 | ->append('PanelProvider'); 35 | 36 | $path = app_path( 37 | (string) str($basename) 38 | ->prepend('Providers/Filament/') 39 | ->replace('\\', '/') 40 | ->append('.php'), 41 | ); 42 | 43 | if (! $isForced && $this->checkForCollision([$path])) { 44 | throw new FailureCommandOutput; 45 | } 46 | 47 | $fqn = app()->getNamespace() . "Providers\\Filament\\{$basename}"; 48 | 49 | if (empty(Filament::getPanels())) { 50 | $this->writeFile($path, app(ModulePanelProviderClassGenerator::class, [ 51 | 'fqn' => $fqn, 52 | 'id' => $id, 53 | 'isDefault' => true, 54 | ])); 55 | } else { 56 | $this->writeFile($path, app(ModulePanelProviderClassGenerator::class, [ 57 | 'fqn' => $fqn, 58 | 'id' => $id, 59 | ])); 60 | } 61 | 62 | $hasBootstrapProvidersFile = file_exists($bootstrapProvidersPath = App::getBootstrapProvidersPath()); 63 | 64 | if ($hasBootstrapProvidersFile) { 65 | ServiceProvider::addProviderToBootstrapFile( 66 | $fqn, 67 | $bootstrapProvidersPath, 68 | ); 69 | } else { 70 | $appConfig = file_get_contents(config_path('app.php')); 71 | 72 | if (! Str::contains($appConfig, "{$fqn}::class")) { 73 | file_put_contents(config_path('app.php'), str_replace( 74 | app()->getNamespace() . 'Providers\\RouteServiceProvider::class,', 75 | "{$fqn}::class," . PHP_EOL . ' ' . app()->getNamespace() . 'Providers\\RouteServiceProvider::class,', 76 | $appConfig, 77 | )); 78 | } 79 | } 80 | 81 | $this->components->info("Filament panel [{$path}] created successfully."); 82 | 83 | if ($hasBootstrapProvidersFile) { 84 | $this->components->warn("We've attempted to register the {$basename} in your [bootstrap/providers.php] file. If you get an error while trying to access your panel then this process has probably failed. You can manually register the service provider by adding it to the array."); 85 | } else { 86 | $this->components->warn("We've attempted to register the {$basename} in your [config/app.php] file as a service provider. If you get an error while trying to access your panel then this process has probably failed. You can manually register the service provider by adding it to the [providers] array."); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Commands/ModuleMakeFilamentClusterCommand.php: -------------------------------------------------------------------------------- 1 | ensureModuleArgument(); 34 | $this->ensurePanel(); 35 | 36 | return parent::handle(); 37 | } 38 | 39 | public function ensureModuleArgument(): void 40 | { 41 | if (! $this->argument('module')) { 42 | $module = select('Please select the module to create the cluster in:', \Module::allEnabled()); 43 | if (! $module) { 44 | $this->error('No module selected. Aborting cluster creation.'); 45 | exit(1); 46 | } 47 | $this->input->setArgument('module', $module); 48 | } 49 | } 50 | 51 | public function ensurePanel(): void 52 | { 53 | if (! $this->option('panel')) { 54 | $panels = FilamentModules::getModulePanels($this->argument('module')); 55 | $defaultPanel = filament()->getDefaultPanel(); 56 | $options = collect([ 57 | $defaultPanel, 58 | ...$panels, 59 | ])->mapWithKeys(function ($panel) { 60 | return [$panel->getId() => $panel->getId()]; 61 | })->toArray(); 62 | 63 | $panel = select('Please select the panel to create the cluster in:', $options); 64 | if (! $panel) { 65 | $this->error('No panel selected. Aborting cluster creation.'); 66 | exit(1); 67 | } 68 | $this->input->setOption('panel', $panel); 69 | } 70 | } 71 | 72 | protected function configureClustersLocation(): void 73 | { 74 | $modulePanels = FilamentModules::getModulePanels($this->argument('module')); 75 | // Check if the panel is in the module 76 | $inModule = collect($modulePanels)->first(fn ($panel) => $panel->getId() === $this->panel->getId()); 77 | if ($inModule) { 78 | $directories = $this->panel->getClusterDirectories(); 79 | $namespaces = $this->panel->getClusterNamespaces(); 80 | } else { 81 | // Get the default Cluster directories and namespaces in the module 82 | $directories = [$this->getModule()->appPath('Filament/Clusters/')]; 83 | $namespaces = [$this->getModule()->appNamespace('Filament\\Clusters')]; 84 | } 85 | 86 | foreach ($directories as $index => $directory) { 87 | if (str($directory)->startsWith(base_path('vendor'))) { 88 | unset($directories[$index]); 89 | unset($namespaces[$index]); 90 | } 91 | } 92 | 93 | if (count($namespaces) < 2) { 94 | $this->clustersNamespace = (Arr::first($namespaces) ?? $this->getModule()->appNamespace('Filament\\Clusters')); 95 | $this->clustersDirectory = (Arr::first($directories) ?? $this->getModule()->appPath('Filament' . DIRECTORY_SEPARATOR . 'Clusters' . DIRECTORY_SEPARATOR)); 96 | 97 | return; 98 | } 99 | 100 | $keyedNamespaces = array_combine( 101 | $namespaces, 102 | $namespaces, 103 | ); 104 | 105 | $this->clustersNamespace = search( 106 | label: 'Which namespace would you like to create this cluster in?', 107 | options: function (?string $search) use ($keyedNamespaces): array { 108 | if (blank($search)) { 109 | return $keyedNamespaces; 110 | } 111 | 112 | $search = str($search)->trim()->replace(['\\', '/'], ''); 113 | 114 | return array_filter($keyedNamespaces, fn (string $namespace): bool => str($namespace)->replace(['\\', '/'], '')->contains($search, ignoreCase: true)); 115 | }, 116 | ); 117 | $this->clustersDirectory = $directories[array_search($this->clustersNamespace, $namespaces)]; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Commands/ModuleMakeFilamentThemeCommand.php: -------------------------------------------------------------------------------- 1 | getModule(); 22 | 23 | $this->call('vendor:publish', [ 24 | '--provider' => 'Nwidart\Modules\LaravelModulesServiceProvider', 25 | '--tag' => 'vite', 26 | ]); 27 | 28 | $pm = $this->option('pm') ?? 'npm'; 29 | 30 | exec("{$pm} -v", $pmVersion, $pmVersionExistCode); 31 | 32 | if ($pmVersionExistCode !== 0) { 33 | $this->error('Node.js is not installed. Please install before continuing.'); 34 | 35 | return static::FAILURE; 36 | } 37 | 38 | $this->info("Using {$pm} v{$pmVersion[0]}"); 39 | 40 | $installCommand = match ($pm) { 41 | 'yarn' => 'yarn add', 42 | default => "{$pm} install", 43 | }; 44 | $cdCommand = 'cd ' . $module->getPath(); 45 | 46 | exec("$cdCommand && {$installCommand} tailwindcss @tailwindcss/forms @tailwindcss/typography postcss postcss-nesting autoprefixer --save-dev"); 47 | 48 | // $panel = $this->argument('panel'); 49 | 50 | $cssFilePath = $module->resourcesPath('css/filament/theme.css'); 51 | $tailwindConfigFilePath = $module->resourcesPath('css/filament/tailwind.config.js'); 52 | 53 | if (! $this->option('force') && $this->checkForCollision([ 54 | $cssFilePath, 55 | $tailwindConfigFilePath, 56 | ])) { 57 | return static::INVALID; 58 | } 59 | 60 | $classPathPrefix = ''; 61 | 62 | $viewPathPrefix = ''; 63 | 64 | $this->copyStubToApp('filament-theme-css', $cssFilePath); 65 | $this->copyStubToApp('filament-theme-tailwind-config', $tailwindConfigFilePath, [ 66 | 'classPathPrefix' => $classPathPrefix, 67 | 'viewPathPrefix' => $viewPathPrefix, 68 | ]); 69 | 70 | $this->components->info('Filament theme [resources/css/filament/theme.css] and [resources/css/filament/tailwind.config.js] created successfully in ' . $module->getStudlyName() . ' module.'); 71 | 72 | $buildDirectory = 'build-' . $module->getLowerName(); 73 | $moduleStudlyName = $module->getStudlyName(); 74 | 75 | if (empty(glob($module->getExtraPath('vite.config.*s')))) { 76 | $this->components->warn('Action is required to complete the theme setup:'); 77 | $this->components->bulletList([ 78 | "It looks like you don't have Vite installed in your module. Please use your asset bundling system of choice to compile `resources/css/filament/theme.css` into `public/$buildDirectory/css/filament/theme.css`.", 79 | "If you're not currently using a bundler, we recommend using Vite. Alternatively, you can use the Tailwind CLI with the following command inside the $moduleStudlyName module:", 80 | 'npx tailwindcss --input ./resources/css/filament/theme.css --output ./public/' . $buildDirectory . '/css/filament/theme.css --config ./resources/css/filament/tailwind.config.js --minify', 81 | "Make sure to register the theme in the {$moduleStudlyName} module plugin under the afterRegister() function using `->theme(asset('css/filament/theme.css'))`", 82 | ]); 83 | 84 | return static::SUCCESS; 85 | } 86 | 87 | $postcssConfigPath = $module->getExtraPath('postcss.config.js'); 88 | 89 | if (! file_exists($postcssConfigPath)) { 90 | $this->copyStubToApp('filament-theme-postcss', $postcssConfigPath); 91 | 92 | $this->components->info('Filament theme [postcss.config.js] created successfully.'); 93 | } 94 | 95 | $this->components->warn('Action is required to complete the theme setup:'); 96 | $this->components->bulletList([ 97 | "First, add a new item to the `input` array of `vite.config.js`: `resources/css/filament/theme.css` in the $moduleStudlyName module.", 98 | "Next, register the theme in the {$module->getStudlyName()} module plugin under the `afterRegister()` method using `->viteTheme('resources/css/filament/theme.css', '$buildDirectory')`", 99 | "Finally, run `{$pm} run build` from the root of this module to compile the theme.", 100 | ]); 101 | 102 | return static::SUCCESS; 103 | } 104 | 105 | protected function getDefaultStubPath(): string 106 | { 107 | return __DIR__ . '/stubs'; 108 | } 109 | 110 | private function getModule(): Module 111 | { 112 | $moduleName = $this->argument('module') ?? text('In which Module should we create this?', 'e.g Blog', required: true); 113 | $moduleStudlyName = str($moduleName)->studly()->toString(); 114 | 115 | return FilamentModules::getModule($moduleStudlyName); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Commands/ModuleFilamentInstallCommand.php: -------------------------------------------------------------------------------- 1 | moduleName = $this->argument('module'); 47 | $this->mode = ConfigMode::tryFrom(\Config::get('filament-modules.mode', ConfigMode::BOTH->value)); 48 | 49 | if (! $this->option('cluster')) { 50 | $this->cluster = confirm('Do you want to organize your code into filament clusters?', true); 51 | } 52 | // Ensure the Filament directories exist 53 | $this->ensureFilamentDirectoriesExist(); 54 | 55 | if ($this->mode->shouldRegisterPlugins()) { 56 | // Create Filament Plugin 57 | $this->createDefaultFilamentPlugin(); 58 | } 59 | if ($this->cluster && confirm('Would you like to create a default Cluster for the module?', true)) { 60 | $this->createDefaultFilamentCluster(); 61 | } 62 | 63 | // TODO: Support creation of panels 64 | } 65 | 66 | protected function getArguments(): array 67 | { 68 | return [ 69 | ['module', InputArgument::REQUIRED, 'The name of the module in which to install filament support'], 70 | ]; 71 | } 72 | 73 | protected function getOptions(): array 74 | { 75 | return [ 76 | ['cluster', 'C', InputOption::VALUE_NONE], 77 | ]; 78 | } 79 | 80 | protected function promptForMissingArgumentsUsing(): array 81 | { 82 | return [ 83 | 'module' => [ 84 | 'What is the name of the module?', 85 | 'e.g AccessControl, Blog, etc.', 86 | ], 87 | ]; 88 | } 89 | 90 | protected function getModule(): \Nwidart\Modules\Module 91 | { 92 | try { 93 | return Module::findOrFail($this->moduleName); 94 | } catch (ModuleNotFoundException | \Throwable $exception) { 95 | if (confirm("Module $this->moduleName does not exist. Would you like to generate it?", true)) { 96 | $this->call('module:make', ['name' => [$this->moduleName]]); 97 | 98 | return $this->getModule(); 99 | } 100 | $this->error($exception->getMessage()); 101 | exit(1); 102 | } 103 | } 104 | 105 | private function ensureFilamentDirectoriesExist(): void 106 | { 107 | if (! is_dir($dir = $this->getModule()->appPath('Filament'))) { 108 | $this->makeDirectory($dir); 109 | } 110 | 111 | if (! is_dir($dir = $this->getModule()->appPath('Providers' . DIRECTORY_SEPARATOR . 'Filament'))) { 112 | $this->makeDirectory($dir); 113 | } 114 | 115 | if ($this->cluster) { 116 | $dir = $this->getModule()->appPath('Filament/Clusters'); 117 | if (! is_dir($dir = $this->getModule()->appPath('Filament/Clusters'))) { 118 | $this->makeDirectory($dir); 119 | } 120 | 121 | } else { 122 | if (! is_dir($dir = $this->getModule()->appPath('Filament/Pages'))) { 123 | $this->makeDirectory($dir); 124 | } 125 | 126 | if (! is_dir($dir = $this->getModule()->appPath('Filament/Resources'))) { 127 | $this->makeDirectory($dir); 128 | } 129 | 130 | if (! is_dir($dir = $this->getModule()->appPath('Filament/Widgets'))) { 131 | $this->makeDirectory($dir); 132 | } 133 | } 134 | } 135 | 136 | private function makeDirectory(string $dir): void 137 | { 138 | if (! mkdir($dir, 0755, true) && ! is_dir($dir)) { 139 | $this->error(sprintf('Directory "%s" was not created', $dir)); 140 | exit(1); 141 | } 142 | } 143 | 144 | protected function createDefaultFilamentPlugin(): void 145 | { 146 | $module = $this->getModule(); 147 | $this->call('module:filament:plugin', [ 148 | 'name' => $module->getStudlyName() . 'Plugin', 149 | 'module' => $module->getStudlyName(), 150 | ]); 151 | } 152 | 153 | protected function createDefaultFilamentCluster(): void 154 | { 155 | $module = $this->getModule(); 156 | $this->call('module:filament:cluster', [ 157 | 'name' => $module->getStudlyName(), 158 | 'module' => $module->getStudlyName(), 159 | '--panel' => filament()->getDefaultPanel()->getId(), 160 | ]); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/ModulesPlugin.php: -------------------------------------------------------------------------------- 1 | topNavigation(config('filament-modules.clusters.enabled', false) && config('filament-modules.clusters.use-top-navigation', false)); 23 | $mode = ConfigMode::tryFrom(config('filament-modules.mode', ConfigMode::BOTH->value)); 24 | if ($mode?->shouldRegisterPlugins()) { 25 | $plugins = $this->getModulePlugins(); 26 | foreach ($plugins as $modulePlugin) { 27 | $panel->plugin($modulePlugin::make()); 28 | } 29 | } 30 | } 31 | 32 | public function boot(Panel $panel): void 33 | { 34 | // Register panels 35 | $mode = ConfigMode::tryFrom(config('filament-modules.mode', ConfigMode::BOTH->value)); 36 | if ($mode?->shouldRegisterPanels()) { 37 | $group = config('filament-modules.panels.group', 'Modules'); 38 | $groupIcon = config('filament-modules.panels.group-icon', \Filament\Support\Icons\Heroicon::OutlinedRectangleStack); 39 | $groupSort = config('filament-modules.panels.group-sort', 0); 40 | $openInNewTab = config('filament-modules.panels.open-in-new-tab', false); 41 | 42 | $panels = $this->getModulePanels(); 43 | $panel->navigationGroups([ 44 | NavigationGroup::make($group) 45 | ->icon($groupIcon) 46 | ->collapsed(), 47 | ]); 48 | $navItems = collect($panels)->map(function (Panel $panel) use ($group, $groupSort, $openInNewTab) { 49 | $moduleName = str($panel->getPath())->before('/'); 50 | $module = \Module::find($moduleName); 51 | if (! $module) { 52 | return null; 53 | } 54 | // $panelLabel = str($panel->getId())->after($moduleName)->trim('-')->snake()->title()->replace('_', ' '); 55 | // $label = str($module->getTitle())->append(" - ")->append($panelLabel); 56 | $label = $panel->getBrandName() ?? str($panel->getId())->after($moduleName)->trim('-')->studly()->snake()->replace('_', ' ')->toString(); 57 | 58 | return NavigationItem::make($label) 59 | ->group($group) 60 | ->sort($groupSort) 61 | ->url($panel->getUrl()) 62 | ->openUrlInNewTab($openInNewTab); 63 | })->toArray(); 64 | $panel->navigationItems($navItems); 65 | } 66 | } 67 | 68 | public static function make(): static 69 | { 70 | return app(static::class); 71 | } 72 | 73 | public static function get(): static 74 | { 75 | /** @var static $plugin */ 76 | $plugin = filament(app(static::class)->getId()); 77 | 78 | return $plugin; 79 | } 80 | 81 | protected function getModulePlugins(): array 82 | { 83 | if (! config('filament-modules.auto-register-plugins', false)) { 84 | return []; 85 | } 86 | // get a glob of all Filament plugins 87 | $basePath = str(config('modules.paths.modules', 'Modules')); 88 | $appFolder = trim(config('modules.paths.app_folder', 'app'), '/\\'); 89 | $appPath = $appFolder . DIRECTORY_SEPARATOR; 90 | $pattern = str($basePath . DIRECTORY_SEPARATOR . '*' . DIRECTORY_SEPARATOR . $appPath . 'Filament' . DIRECTORY_SEPARATOR . '*Plugin.php')->replace('//', '/')->toString(); 91 | $pluginPaths = glob($pattern); 92 | 93 | return collect($pluginPaths)->map(fn ($path) => FilamentModules::convertPathToNamespace($path))->toArray(); 94 | 95 | } 96 | 97 | /** 98 | * Get all Filament panels registered by modules. 99 | * 100 | * @return Panel[] 101 | */ 102 | protected function getModulePanels(): array 103 | { 104 | // get a glob of all Filament panels 105 | $basePath = str(config('modules.paths.modules', 'Modules')); 106 | $appFolder = str(config('modules.paths.app_folder', 'app')); 107 | $pattern = $basePath . DIRECTORY_SEPARATOR . '*' . DIRECTORY_SEPARATOR . $appFolder . DIRECTORY_SEPARATOR . 'Providers' . DIRECTORY_SEPARATOR . 'Filament' . DIRECTORY_SEPARATOR . '*.php'; 108 | $panelPaths = glob($pattern); 109 | 110 | $panelIds = collect($panelPaths)->map(fn ($path) => FilamentModules::convertPathToNamespace($path))->map(function ($class) { 111 | // Get the panel ID and check if it is registered 112 | $id = str($class)->afterLast('\\')->before('PanelProvider')->kebab()->lower(); 113 | // get module it belongs to as well 114 | $moduleName = str($class)->after('Modules\\')->before('\\Providers\\Filament'); 115 | $module = \Module::find($moduleName); 116 | if (! $module) { 117 | return null; 118 | } 119 | 120 | return str($id)->prepend('-')->prepend($module->getKebabName()); 121 | }); 122 | 123 | return collect(filament()->getPanels())->filter(function ($panel) use ($panelIds) { 124 | // Check if the panel ID is in the list of panel IDs 125 | return $panelIds->contains($panel->getId()); 126 | })->values()->all(); 127 | 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Concerns/GeneratesModularFiles.php: -------------------------------------------------------------------------------- 1 | argument('module')); 31 | } 32 | 33 | protected function getDefaultNamespace($rootNamespace): string 34 | { 35 | return trim($rootNamespace, '\\') . '\\' . trim(Str::replace(DIRECTORY_SEPARATOR, '\\', $this->getRelativeNamespace()), '\\'); 36 | } 37 | 38 | abstract protected function getRelativeNamespace(): string; 39 | 40 | protected function rootNamespace(): string 41 | { 42 | return $this->getModule()->namespace(''); 43 | } 44 | 45 | protected function getPath($name): string 46 | { 47 | $appFolder = trim(config('modules.paths.app_folder', 'app/'), '/\\'); 48 | $rootNamespace = str($this->rootNamespace())->trim('\\')->toString(); 49 | $name = Str::replaceFirst($rootNamespace, $appFolder, $name); 50 | 51 | return $this->getModule()->getExtraPath(str_replace('\\', DIRECTORY_SEPARATOR, $name) . '.php'); 52 | } 53 | 54 | protected function possibleModels() 55 | { 56 | $appFolder = trim(config('modules.paths.app_folder', 'app/'), '/\\'); 57 | $modelPath = str(config('modules.paths.model_folder', 'app/Models')) 58 | ->replaceFirst($appFolder, '')->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR)->trim(DIRECTORY_SEPARATOR)->toString(); 59 | $modelPath = $this->getModule()->appPath($modelPath); 60 | 61 | return collect(Finder::create()->files()->depth(0)->in($modelPath)) 62 | ->map(fn ($file) => $file->getBasename('.php')) 63 | ->sort() 64 | ->values() 65 | ->all(); 66 | } 67 | 68 | public function possibleFqnModels(): array 69 | { 70 | return collect($this->possibleModels()) 71 | ->map(fn ($model) => str($this->getModule()->appNamespace('Models'))->trim('\\')->append("\\{$model}")->toString()) 72 | ->all(); 73 | } 74 | 75 | protected function viewPath($path = ''): string 76 | { 77 | $views = $this->getModule()->resourcesPath('views'); 78 | 79 | return $views . ($path ? DIRECTORY_SEPARATOR . $path : $path); 80 | } 81 | 82 | protected function buildClass($name) 83 | { 84 | $stub = $this->files->get($this->getStub()); 85 | 86 | return $this->replaceNamespace($stub, $name)->applyStubReplacements($stub)->replaceClass($stub, $name); 87 | } 88 | 89 | protected function applyStubReplacements(&$stub): static 90 | { 91 | foreach ($this->stubReplacements() as $key => $replacement) { 92 | $stub = str_replace(["{{ $key }}", "{{{$key}}}"], $replacement, $stub); 93 | } 94 | 95 | return $this; 96 | } 97 | 98 | protected function stubReplacements(): array 99 | { 100 | return []; 101 | } 102 | 103 | protected function promptForMissingArgumentsUsing(): array 104 | { 105 | return [ 106 | 'name' => [ 107 | 'What should the ' . strtolower($this->type ?: 'class') . ' be named?', 108 | match ($this->type) { 109 | 'Cast' => 'E.g. Json', 110 | 'Channel' => 'E.g. OrderChannel', 111 | 'Console command' => 'E.g. SendEmails', 112 | 'Component' => 'E.g. Alert', 113 | 'Controller' => 'E.g. UserController', 114 | 'Event' => 'E.g. PodcastProcessed', 115 | 'Exception' => 'E.g. InvalidOrderException', 116 | 'Factory' => 'E.g. PostFactory', 117 | 'Job' => 'E.g. ProcessPodcast', 118 | 'Listener' => 'E.g. SendPodcastNotification', 119 | 'Mailable' => 'E.g. OrderShipped', 120 | 'Middleware' => 'E.g. EnsureTokenIsValid', 121 | 'Model' => 'E.g. Flight', 122 | 'Notification' => 'E.g. InvoicePaid', 123 | 'Observer' => 'E.g. UserObserver', 124 | 'Policy' => 'E.g. PostPolicy', 125 | 'Provider' => 'E.g. ElasticServiceProvider', 126 | 'Request' => 'E.g. StorePodcastRequest', 127 | 'Resource' => 'E.g. UserResource', 128 | 'Rule' => 'E.g. Uppercase', 129 | 'Scope' => 'E.g. TrendingScope', 130 | 'Seeder' => 'E.g. UserSeeder', 131 | 'Test' => 'E.g. UserTest', 132 | 'Filament Cluster' => 'E.g Settings', 133 | 'Filament Plugin' => 'e.g AccessControlPlugin', 134 | default => '', 135 | }, 136 | ], 137 | 'module' => [ 138 | 'In which Module should we create this?', 139 | 'e.g Blog', 140 | true, 141 | ], 142 | ]; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/Modules.php: -------------------------------------------------------------------------------- 1 | getModule($moduleName); 27 | 28 | // Scan the Providers/Filament directory of the panel for providers 29 | $panelPath = $module->appPath('Providers' . DIRECTORY_SEPARATOR . 'Filament'); 30 | $module = Module::find($moduleName); 31 | if (! $module || ! is_dir($panelPath)) { 32 | return []; 33 | } 34 | $pattern = $panelPath . DIRECTORY_SEPARATOR . '*PanelProvider.php'; 35 | $panelPaths = glob($pattern); 36 | $panels_ids = collect($panelPaths)->map(function ($path) use ($moduleName) { 37 | // Convert the path to a namespace 38 | $namespace = $this->convertPathToNamespace($path); 39 | // Get the panel ID from the class name 40 | $id = str($namespace)->afterLast('\\')->before('PanelProvider')->kebab()->lower(); 41 | 42 | return str($id)->prepend('-')->prepend($this->getModule($moduleName)->getKebabName()); 43 | }); 44 | 45 | return collect(filament()->getPanels())->filter(function ($panel) use ($panels_ids) { 46 | return $panels_ids->contains($panel->getId()); 47 | })->values()->all(); 48 | } 49 | 50 | public function getModuleClusters(string $moduleName) 51 | { 52 | $module = $this->getModule($moduleName); 53 | 54 | // Scan the Clusters directory of the module for clusters 55 | $clusterPath = $module->appPath('Filament' . DIRECTORY_SEPARATOR . 'Clusters'); 56 | if (! is_dir($clusterPath)) { 57 | return []; 58 | } 59 | $pattern = $clusterPath . DIRECTORY_SEPARATOR . '*' . DIRECTORY_SEPARATOR . '*Cluster.php'; 60 | $clusterPaths = glob($pattern); 61 | 62 | return collect($clusterPaths)->map(function ($path) { 63 | // Convert the path to a namespace 64 | return $this->convertPathToNamespace($path); 65 | })->all(); 66 | } 67 | 68 | public function convertPathToNamespace(string $fullPath): string 69 | { 70 | $appFolder = trim(config('modules.paths.app_folder', 'app'), '/\\'); 71 | $appPath = $appFolder . DIRECTORY_SEPARATOR; 72 | $base = str(trim(config('modules.paths.modules', base_path('Modules')), '/\\')); 73 | $replacementPath = str_replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, '/', DIRECTORY_SEPARATOR . $appPath); 74 | $relative = str($fullPath)->afterLast($base)->replaceFirst($replacementPath, DIRECTORY_SEPARATOR); 75 | 76 | return str($relative) 77 | ->ltrim('/\\') 78 | ->prepend(DIRECTORY_SEPARATOR) 79 | ->prepend(config('modules.namespace', 'Modules')) 80 | ->replace(DIRECTORY_SEPARATOR, '\\') 81 | ->replace('\\\\', '\\') 82 | ->rtrim('.php') 83 | ->explode(DIRECTORY_SEPARATOR) 84 | ->map(fn ($piece) => str($piece)->studly()->toString()) 85 | ->implode('\\'); 86 | } 87 | 88 | public function execCommand(string $command, ?Command $artisan = null): void 89 | { 90 | $process = Process::fromShellCommandline($command); 91 | $process->start(); 92 | foreach ($process as $type => $data) { 93 | if (! $artisan) { 94 | echo $data; 95 | } else { 96 | $artisan->info(trim($data)); 97 | } 98 | } 99 | } 100 | 101 | public function packagePath(string $path = ''): string 102 | { 103 | // return the base path of this package 104 | return dirname(__DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR) . ($path ? DIRECTORY_SEPARATOR . trim($path, DIRECTORY_SEPARATOR) : ''); 105 | } 106 | 107 | public function getMode(): ?ConfigMode 108 | { 109 | return ConfigMode::tryFrom(config('filament-modules.mode', ConfigMode::BOTH->value)); 110 | } 111 | 112 | public function getModuleFilamentPageComponentLocation(string $moduleName, ?string $panelId = null, bool $forCluster = false): array 113 | { 114 | $module = $this->getModule($moduleName); 115 | $viewPath = $module->getExtraPath('resources' . DIRECTORY_SEPARATOR . 'views' . DIRECTORY_SEPARATOR . 'filament' . DIRECTORY_SEPARATOR . 'pages'); 116 | $componentNamespace = $module->appNamespace('Filament\\Pages'); 117 | if ($panelId) { 118 | $panelDir = str($panelId)->studly()->toString(); 119 | $viewPath = $module->getExtraPath('resources' . DIRECTORY_SEPARATOR . 'views' . DIRECTORY_SEPARATOR . 'filament' . DIRECTORY_SEPARATOR . str($panelDir)->kebab()->toString()); 120 | $componentNamespace = $module->appNamespace('Filament\\' . $panelDir); 121 | } elseif ($forCluster) { 122 | $viewPath = $module->getExtraPath('resources' . DIRECTORY_SEPARATOR . 'views' . DIRECTORY_SEPARATOR . 'filament' . DIRECTORY_SEPARATOR . 'clusters'); 123 | $componentNamespace = $module->appNamespace('Filament\\Clusters'); 124 | } 125 | // Create if it doesn't exist 126 | if (! is_dir($viewPath)) { 127 | mkdir($viewPath, 0755, true); 128 | } 129 | $viewNamespace = $module->getLowerName(); 130 | 131 | return [ 132 | 'namespace' => $componentNamespace, 133 | 'path' => $viewPath, 134 | 'viewNamespace' => $viewNamespace, 135 | ]; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/Commands/ModuleMakeFilamentPanelCommand.php: -------------------------------------------------------------------------------- 1 | 30 | */ 31 | protected $aliases = [ 32 | 'module:filament:make-panel', 33 | 'module:filament:panel', 34 | ]; 35 | 36 | /** 37 | * @return array 38 | */ 39 | protected function getArguments(): array 40 | { 41 | return [ 42 | new InputArgument( 43 | name: 'id', 44 | mode: InputArgument::OPTIONAL, 45 | description: 'The ID of the panel', 46 | ), 47 | new InputArgument( 48 | name: 'module', 49 | mode: InputArgument::OPTIONAL, 50 | description: 'The module to create the panel in', 51 | ), 52 | ]; 53 | } 54 | 55 | /** 56 | * @return array 57 | */ 58 | protected function getOptions(): array 59 | { 60 | return [ 61 | new InputOption( 62 | name: 'force', 63 | shortcut: 'F', 64 | mode: InputOption::VALUE_NONE, 65 | description: 'Overwrite the contents of the files if they already exist', 66 | ), 67 | new InputOption( 68 | name: 'label', 69 | shortcut: null, 70 | mode: InputOption::VALUE_OPTIONAL, 71 | description: 'The navigation label for the panel', 72 | ), 73 | ]; 74 | } 75 | 76 | public function handle(): int 77 | { 78 | try { 79 | $this->ensureModuleArgument(); 80 | $this->generatePanel( 81 | id: $this->argument('id'), 82 | placeholderId: 'default', 83 | isForced: $this->option('force'), 84 | ); 85 | } catch (FailureCommandOutput) { 86 | return static::FAILURE; 87 | } 88 | 89 | return static::SUCCESS; 90 | } 91 | 92 | protected function ensureNavigationLabelOption(): void 93 | { 94 | if (! $this->option('label')) { 95 | $label = text( 96 | label: 'What is the navigation label for the panel?', 97 | placeholder: Str::title($this->argument('id') ?? $this->getModule()->getName() . ' App'), 98 | required: true, 99 | validate: fn (string $value) => empty($value) ? 'The navigation label cannot be empty.' : null, 100 | hint: 'This is used in the navigation to identify the panel.', 101 | ); 102 | if (empty($label)) { 103 | $this->components->error('Navigation label cannot be empty. Aborting panel creation.'); 104 | exit(1); 105 | } 106 | $this->input->setOption('label', $label); 107 | } 108 | } 109 | 110 | protected function ensureModuleArgument(): void 111 | { 112 | if (! $this->argument('module')) { 113 | $module = select('Please select the module to create the panel in:', \Module::allEnabled()); 114 | if (! $module) { 115 | $this->components->error('No module selected. Aborting panel creation.'); 116 | exit(1); 117 | } 118 | $this->input->setArgument('module', $module); 119 | } 120 | } 121 | 122 | protected function getRelativeNamespace(): string 123 | { 124 | return 'Providers\\Filament'; 125 | } 126 | 127 | /** 128 | * @throws FailureCommandOutput 129 | */ 130 | public function generatePanel(?string $id = null, string $defaultId = '', string $placeholderId = '', bool $isForced = false): void 131 | { 132 | $module = $this->getModule(); 133 | $this->components->info("Creating Filament panel in module [{$module->getName()}]..."); 134 | $id = Str::lcfirst($id ?? text( 135 | label: 'What is the panel\'s ID?', 136 | placeholder: $placeholderId, 137 | required: true, 138 | validate: fn (string $value) => match (true) { 139 | preg_match('/^[a-zA-Z].*/', $value) !== false => null, 140 | default => 'The ID must start with a letter, and not a number or special character.', 141 | }, 142 | hint: 'It must be unique to any others you have, and is used to reference the panel in your code.', 143 | )); 144 | if (empty($id)) { 145 | $this->components->error('Panel ID cannot be empty. Aborting panel creation.'); 146 | exit(1); 147 | } 148 | $this->ensureNavigationLabelOption(); 149 | 150 | $basename = (string) str($id) 151 | ->studly() 152 | ->append('PanelProvider'); 153 | 154 | $path = $module->appPath( 155 | (string) str($basename) 156 | ->prepend('/Providers/Filament/') 157 | ->replace('\\', '/') 158 | ->append('.php'), 159 | ); 160 | 161 | if (! $isForced && $this->checkForCollision([$path])) { 162 | throw new FailureCommandOutput; 163 | } 164 | 165 | $fqn = $module->appNamespace("Providers\\Filament\\{$basename}"); 166 | 167 | $this->writeFile($path, app(ModulePanelProviderClassGenerator::class, [ 168 | 'fqn' => $fqn, 169 | 'id' => $id, 170 | 'moduleName' => $module->getName(), 171 | 'navigationLabel' => $this->option('label'), 172 | ])); 173 | 174 | $this->components->info("Filament panel [{$path}] created successfully."); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/Commands/ModuleMakeFilamentResourceCommand.php: -------------------------------------------------------------------------------- 1 | ensureModuleArgument(); 45 | $this->ensureModelNamespace(); 46 | $this->ensurePanel(); 47 | 48 | return parent::handle(); 49 | } 50 | 51 | public function ensureModuleArgument(): void 52 | { 53 | if (! $this->argument('module')) { 54 | $module = select('Please select the module to create the resource in:', Module::allEnabled()); 55 | if (! $module) { 56 | $this->error('No module selected. Aborting resource creation.'); 57 | exit(1); 58 | } 59 | $this->input->setArgument('module', $module); 60 | } 61 | } 62 | 63 | public function ensureModelNamespace(): void 64 | { 65 | $modelNamespace = $this->input->getOption('model-namespace'); 66 | if (! $modelNamespace) { 67 | // try to get from name 68 | $name = $this->input->getArgument('model'); 69 | if ($name) { 70 | $modelName = str_replace('Resource', '', class_basename($name)); 71 | } else { 72 | $modelName = select('Please select the model within this module for the resource:', $this->possibleFqnModels()); 73 | } 74 | 75 | $modelClass = class_basename($modelName); 76 | $modelNamespace = str(trim($modelName, '\\'))->beforeLast("\\{$modelClass}")->toString(); 77 | 78 | if (! $modelName) { 79 | $this->error('No model namespace selected. Aborting resource creation.'); 80 | exit(1); 81 | } 82 | $modelName = $modelClass; 83 | 84 | $this->input->setOption('model-namespace', $modelNamespace); 85 | $this->input->setArgument('model', $modelName); 86 | 87 | $this->output->info("Using model namespace: {$modelNamespace}"); 88 | $this->output->info("Using model name: {$modelName}"); 89 | } 90 | } 91 | 92 | public function ensurePanel() 93 | { 94 | $defaultPanel = filament()->getDefaultPanel(); 95 | if (! FilamentModules::getMode()->shouldRegisterPanels()) { 96 | $this->panel = $defaultPanel; 97 | } else { 98 | $modulePanels = FilamentModules::getModulePanels($this->getModule()); 99 | if (count($modulePanels) === 0) { 100 | $this->panel = $defaultPanel; 101 | 102 | return; 103 | } 104 | $options = [ 105 | $defaultPanel->getId(), 106 | ...collect($modulePanels)->map(fn ($panel) => $panel->getId())->values()->all(), 107 | ]; 108 | $panelId = select( 109 | label: 'Please select the Filament panel to create the resource in:', 110 | options: $options, 111 | default: $defaultPanel->getId(), 112 | ); 113 | $this->input->setOption('panel', $panelId); 114 | $this->panel = filament()->getPanel($panelId, isStrict: false); 115 | if (! $this->panel) { 116 | $this->error("Panel [{$panelId}] not found. Aborting resource creation."); 117 | exit(1); 118 | } 119 | } 120 | } 121 | 122 | /** 123 | * @throws \Exception 124 | */ 125 | public function getResourcesLocation(string $question): array 126 | { 127 | $modulePanels = FilamentModules::getModulePanels($this->getModule()); 128 | $mode = ConfigMode::tryFrom(config('filament-modules.mode', ConfigMode::BOTH->value)); 129 | if ($mode->shouldRegisterPanels() && in_array($this->panel->getId(), collect($modulePanels)->map(fn ($panel) => $panel->getId())->all())) { 130 | $directories = $this->panel->getResourceDirectories(); 131 | $namespaces = $this->panel->getResourceNamespaces(); 132 | } else { 133 | // Default to the module's filament resources directory 134 | $directories = [ 135 | $this->getModule()->appPath('Filament' . DIRECTORY_SEPARATOR . 'Resources'), 136 | ]; 137 | $namespaces = [ 138 | $this->getModule()->appNamespace('Filament\\Resources'), 139 | ]; 140 | } 141 | 142 | foreach ($directories as $index => $directory) { 143 | if (str($directory)->startsWith(base_path('vendor'))) { 144 | unset($directories[$index]); 145 | unset($namespaces[$index]); 146 | } 147 | } 148 | 149 | if (count($namespaces) < 2) { 150 | return [ 151 | (Arr::first($namespaces) ?? $this->getModule()->appNamespace('Filament\\Resources')), 152 | (Arr::first($directories) ?? $this->getModule()->appPath('Filament' . DIRECTORY_SEPARATOR . 'Resources')), 153 | ]; 154 | } 155 | 156 | if ($this->option('resource-namespace')) { 157 | return [ 158 | (string) $this->option('resource-namespace'), 159 | $directories[array_search($this->option('resource-namespace'), $namespaces)], 160 | ]; 161 | } 162 | 163 | $keyedNamespaces = array_combine( 164 | $namespaces, 165 | $namespaces, 166 | ); 167 | 168 | return [ 169 | $namespace = search( 170 | label: $question, 171 | options: function (?string $search) use ($keyedNamespaces): array { 172 | if (blank($search)) { 173 | return $keyedNamespaces; 174 | } 175 | 176 | $search = str($search)->trim()->replace(['\\', '/'], ''); 177 | 178 | return array_filter($keyedNamespaces, fn (string $namespace): bool => str($namespace)->replace(['\\', '/'], '')->contains($search, ignoreCase: true)); 179 | }, 180 | ), 181 | $directories[array_search($namespace, $namespaces)], 182 | ]; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/Commands/ModuleMakeFilamentWidgetCommand.php: -------------------------------------------------------------------------------- 1 | ensureModule(); 33 | $this->ensurePanel(); 34 | 35 | return parent::handle(); 36 | } 37 | 38 | protected function getRelativeNamespace(): string 39 | { 40 | return 'Filament\\Widgets'; 41 | } 42 | 43 | public function ensureModule() 44 | { 45 | if (! $this->argument('module')) { 46 | $module = select('Please select the module to create the page in:', \Module::allEnabled()); 47 | if (! $module) { 48 | $this->error('No module selected. Aborting page creation.'); 49 | exit(1); 50 | } 51 | $this->input->setArgument('module', $module); 52 | } 53 | } 54 | 55 | public function ensurePanel(): void 56 | { 57 | if (! $this->option('panel')) { 58 | $defaultPanel = filament()->getDefaultPanel(); 59 | 60 | if (! FilamentModules::getMode()->shouldRegisterPanels()) { 61 | $this->input->setOption('panel', $defaultPanel->getId()); 62 | 63 | return; 64 | } 65 | $panels = FilamentModules::getModulePanels($this->argument('module')); 66 | if (empty($panels)) { 67 | $this->input->setOption('panel', $defaultPanel->getId()); 68 | 69 | return; 70 | } 71 | $options = collect([ 72 | $defaultPanel, 73 | ...$panels, 74 | ])->mapWithKeys(function (Panel $panel) { 75 | return [$panel->getId() => $panel->getId()]; 76 | })->toArray(); 77 | 78 | $selectedPanel = select( 79 | 'Please select the panel to create the page in:', 80 | $options, 81 | default: $defaultPanel->getId(), 82 | ); 83 | 84 | if (! $selectedPanel) { 85 | $this->error('No panel selected. Aborting page creation.'); 86 | exit(1); 87 | } 88 | 89 | $this->input->setOption('panel', $selectedPanel); 90 | } 91 | } 92 | 93 | protected function configureWidgetsLocation(): void 94 | { 95 | if (filled($this->resourceFqn)) { 96 | return; 97 | } 98 | 99 | if (! $this->panel) { 100 | [ 101 | $this->widgetsNamespace, 102 | $this->widgetsDirectory, 103 | ] = $this->askForLivewireComponentLocation( 104 | question: 'Where would you like to create the widget?', 105 | ); 106 | 107 | return; 108 | } 109 | 110 | // If this is a module panel, then set the widget directory and namespace to the module's Filament Widgets directory 111 | $modulePanels = FilamentModules::getModulePanels($this->getModule()); 112 | if (in_array($this->panel, $modulePanels, true)) { 113 | $directories = $this->panel->getWidgetDirectories(); 114 | $namespaces = $this->panel->getWidgetNamespaces(); 115 | } else { 116 | // Default to the module's filament widgets directory 117 | $directories = [ 118 | $this->getModule()->appPath('Filament' . DIRECTORY_SEPARATOR . 'Widgets'), 119 | ]; 120 | $namespaces = [ 121 | $this->getModule()->appNamespace('Filament\\Widgets'), 122 | ]; 123 | } 124 | 125 | foreach ($directories as $index => $directory) { 126 | if (str($directory)->startsWith(base_path('vendor'))) { 127 | unset($directories[$index]); 128 | unset($namespaces[$index]); 129 | } 130 | } 131 | 132 | if (count($namespaces) < 2) { 133 | $this->widgetsNamespace = (Arr::first($namespaces) ?? $this->getModule()->appNamespace('Filament\\Widgets')); 134 | $this->widgetsDirectory = (Arr::first($directories) ?? $this->getModule()->appPath('Filament' . DIRECTORY_SEPARATOR . 'Widgets')); 135 | 136 | return; 137 | } 138 | 139 | $keyedNamespaces = array_combine( 140 | $namespaces, 141 | $namespaces, 142 | ); 143 | 144 | $this->widgetsNamespace = search( 145 | label: 'Which namespace would you like to create this widget in?', 146 | options: function (?string $search) use ($keyedNamespaces): array { 147 | if (blank($search)) { 148 | return $keyedNamespaces; 149 | } 150 | 151 | $search = str($search)->trim()->replace(['\\', '/'], ''); 152 | 153 | return array_filter($keyedNamespaces, fn (string $namespace): bool => str($namespace)->replace(['\\', '/'], '')->contains($search, ignoreCase: true)); 154 | }, 155 | ); 156 | $this->widgetsDirectory = $directories[array_search($this->widgetsNamespace, $namespaces)]; 157 | } 158 | 159 | protected function configureLocation(): void 160 | { 161 | $this->fqn = $this->widgetsNamespace . '\\' . $this->fqnEnd; 162 | 163 | if ($this->type === Widget::class) { 164 | $componentLocations = FilamentCli::getLivewireComponentLocations(); 165 | 166 | $matchingComponentLocationNamespaces = collect($componentLocations) 167 | ->keys() 168 | ->filter(fn (string $namespace): bool => str($this->fqn)->startsWith($namespace)); 169 | 170 | [ 171 | $this->view, 172 | $this->viewPath, 173 | ] = $this->askForViewLocation( 174 | view: str($this->fqn) 175 | ->whenContains( 176 | 'Filament\\', 177 | fn (Stringable $fqn) => $fqn->after('Filament\\')->prepend('Filament\\'), 178 | fn (Stringable $fqn) => $fqn 179 | ->afterLast('\\Livewire\\') 180 | ->prepend('Livewire\\'), 181 | ) 182 | ->replace('\\', '/') 183 | ->explode('/') 184 | ->map(Str::kebab(...)) 185 | ->implode('.'), 186 | question: 'Where would you like to create the Blade view for the widget?', 187 | defaultNamespace: (count($matchingComponentLocationNamespaces) === 1) 188 | ? $componentLocations[Arr::first($matchingComponentLocationNamespaces)]['viewNamespace'] ?? null 189 | : null, 190 | ); 191 | } 192 | } 193 | 194 | protected function askForLivewireComponentLocation(string $question = 'Where would you like to create the Livewire component?'): array 195 | { 196 | $locations = FilamentCli::getLivewireComponentLocations(); 197 | 198 | if (blank($locations)) { 199 | return [ 200 | $this->getModule()->appNamespace('Livewire'), 201 | $this->getModule()->appPath('Livewire'), 202 | '', 203 | ]; 204 | } 205 | 206 | $options = [ 207 | null => $this->getModule()->appNamespace('Livewire'), 208 | ...array_combine( 209 | array_keys($locations), 210 | array_keys($locations), 211 | ), 212 | ]; 213 | 214 | $namespace = select( 215 | label: $question, 216 | options: $options, 217 | ); 218 | 219 | if (blank($namespace)) { 220 | return [ 221 | $this->getModule()->appNamespace('Livewire'), 222 | $this->getModule()->appPath('Livewire'), 223 | '', 224 | ]; 225 | } 226 | 227 | return [ 228 | $namespace, 229 | $locations[$namespace]['path'], 230 | $locations[$namespace]['viewNamespace'] ?? null, 231 | ]; 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/Commands/FileGenerators/ModulePanelProviderClassGenerator.php: -------------------------------------------------------------------------------- 1 | module = \Module::find($this->moduleName); 40 | if (! $this->module) { 41 | throw new \InvalidArgumentException("Module '{$this->moduleName}' not found."); 42 | } 43 | } 44 | 45 | public function getNamespace(): string 46 | { 47 | return $this->extractNamespace($this->getFqn()); 48 | } 49 | 50 | /** 51 | * @return array 52 | */ 53 | public function getImports(): array 54 | { 55 | return [ 56 | Panel::class, 57 | $this->getExtends(), 58 | Color::class, 59 | Dashboard::class, 60 | AccountWidget::class, 61 | FilamentInfoWidget::class, 62 | EncryptCookies::class, 63 | AddQueuedCookiesToResponse::class, 64 | StartSession::class, 65 | AuthenticateSession::class, 66 | ShareErrorsFromSession::class, 67 | VerifyCsrfToken::class, 68 | SubstituteBindings::class, 69 | DisableBladeIconComponents::class, 70 | DispatchServingFilamentEvent::class, 71 | Authenticate::class, 72 | ]; 73 | } 74 | 75 | public function getBasename(): string 76 | { 77 | return class_basename($this->getFqn()); 78 | } 79 | 80 | public function getExtends(): string 81 | { 82 | return PanelProvider::class; 83 | } 84 | 85 | protected function addMethodsToClass(ClassType $class): void 86 | { 87 | $this->addPanelMethodToClass($class); 88 | $this->addNavigationLabelMethodToClass($class); 89 | } 90 | 91 | public function getModule(): \Nwidart\Modules\Module 92 | { 93 | return $this->module; 94 | } 95 | 96 | protected function addPanelMethodToClass(ClassType $class): void 97 | { 98 | $method = $class->addMethod('panel') 99 | ->setPublic() 100 | ->setReturnType(Panel::class) 101 | ->setBody($this->generatePanelMethodBody()); 102 | $method->addParameter('panel') 103 | ->setType(Panel::class); 104 | 105 | $this->configurePanelMethod($method); 106 | } 107 | 108 | protected function addNavigationLabelMethodToClass(ClassType $class): void 109 | { 110 | $class->addMethod('getNavigationLabel') 111 | ->setPublic() 112 | ->setReturnType('string') 113 | ->setBody($this->generateNavigationLabelMethodBody()); 114 | } 115 | 116 | protected function generateNavigationLabelMethodBody(): string 117 | { 118 | $navigationLabel = $this->navigationLabel; 119 | 120 | return new Literal( 121 | <<isDefault(); 130 | 131 | $defaultOutput = $isDefault 132 | ? <<<'PHP' 133 | 134 | ->default() 135 | PHP 136 | : ''; 137 | 138 | $loginOutput = $isDefault 139 | ? <<<'PHP' 140 | 141 | ->login() 142 | PHP 143 | : ''; 144 | 145 | $id = str($this->getId())->kebab()->lower()->toString(); 146 | $panelId = str($id)->prepend('-')->prepend($this->getModule()->getKebabName())->toString(); 147 | $urlPath = str($id)->prepend('/')->prepend($this->getModule()->getKebabName())->toString(); 148 | $label = $this->getModule()->getTitle() . ' ' . str($id)->studly()->snake()->title()->replace(['_', '-'], ' ')->toString(); 149 | $componentsDirectory = Str::studly($panelId); 150 | $componentsNamespace = (Str::studly($panelId) . '\\'); 151 | 152 | $rootNamespace = str($this->getModule()->namespace())->rtrim('\\')->append('\\')->toString(); 153 | $moduleName = $this->getModule()->getName(); 154 | 155 | return new Literal( 156 | <<id(?) 160 | ->path(?){$loginOutput} 161 | ->brandName(\$this->getNavigationLabel()) 162 | ->colors([ 163 | 'primary' => {$this->simplifyFqn(Color::class)}::Amber, 164 | ]) 165 | ->discoverResources(in: module("$moduleName", true)->appPath("Filament{\$separator}{$componentsDirectory}{\$separator}Resources"), for: module("$moduleName", true)->appNamespace('Filament\\{$componentsNamespace}Resources')) 166 | ->discoverPages(in:module("$moduleName", true)->appPath("Filament{\$separator}{$componentsDirectory}{\$separator}Pages"), for: module("$moduleName", true)->appNamespace('Filament\\{$componentsNamespace}Pages')) 167 | ->pages([ 168 | {$this->simplifyFqn(Dashboard::class)}::class, 169 | ]) 170 | ->discoverWidgets(in:module("$moduleName", true)->appPath("Filament{\$separator}{$componentsDirectory}{\$separator}Widgets"), for: module("$moduleName", true)->appNamespace('Filament\\{$componentsNamespace}Widgets')) 171 | ->widgets([ 172 | {$this->simplifyFqn(AccountWidget::class)}::class, 173 | {$this->simplifyFqn(FilamentInfoWidget::class)}::class, 174 | ]) 175 | ->discoverClusters(in: module("$moduleName", true)->appPath("Filament{\$separator}{$componentsDirectory}{\$separator}Clusters"), for: module("$moduleName", true)->appNamespace('Filament\\{$componentsNamespace}Clusters')) 176 | ->middleware([ 177 | {$this->simplifyFqn(EncryptCookies::class)}::class, 178 | {$this->simplifyFqn(AddQueuedCookiesToResponse::class)}::class, 179 | {$this->simplifyFqn(StartSession::class)}::class, 180 | {$this->simplifyFqn(AuthenticateSession::class)}::class, 181 | {$this->simplifyFqn(ShareErrorsFromSession::class)}::class, 182 | {$this->simplifyFqn(VerifyCsrfToken::class)}::class, 183 | {$this->simplifyFqn(SubstituteBindings::class)}::class, 184 | {$this->simplifyFqn(DisableBladeIconComponents::class)}::class, 185 | {$this->simplifyFqn(DispatchServingFilamentEvent::class)}::class, 186 | ]) 187 | ->authMiddleware([ 188 | {$this->simplifyFqn(Authenticate::class)}::class, 189 | ])->navigationItems([ 190 | // Add a backlink to the default panel 191 | {$this->simplifyFqn(NavigationItem::class)}::make() 192 | ->label(__('Back Home')) 193 | ->sort(-1000) 194 | ->icon(\Filament\Support\Icons\Heroicon::OutlinedHomeModern) 195 | ->url(filament()->getDefaultPanel()->getUrl()), 196 | ]); 197 | PHP, 198 | [$panelId, $urlPath], 199 | ); 200 | } 201 | 202 | protected function configurePanelMethod(Method $method): void {} 203 | 204 | public function getFqn(): string 205 | { 206 | return $this->fqn; 207 | } 208 | 209 | public function getId(): string 210 | { 211 | return $this->id; 212 | } 213 | 214 | public function isDefault(): bool 215 | { 216 | return $this->isDefault; 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/Commands/ModuleMakeFilamentPageCommand.php: -------------------------------------------------------------------------------- 1 | ensureModuleArgument(); 47 | $this->ensurePanel(); 48 | 49 | return parent::handle(); 50 | } 51 | 52 | public function ensureModuleArgument(): void 53 | { 54 | if (! $this->argument('module')) { 55 | $module = select('Please select the module to create the page in:', \Module::allEnabled()); 56 | if (! $module) { 57 | $this->error('No module selected. Aborting page creation.'); 58 | exit(1); 59 | } 60 | $this->input->setArgument('module', $module); 61 | } 62 | } 63 | 64 | /** 65 | * @throws NoDefaultPanelSetException 66 | * @throws \Exception 67 | */ 68 | public function ensurePanel(): void 69 | { 70 | if (! $this->option('panel')) { 71 | $defaultPanel = filament()->getDefaultPanel(); 72 | 73 | if (! FilamentModules::getMode()->shouldRegisterPanels()) { 74 | $this->input->setOption('panel', $defaultPanel->getId()); 75 | 76 | return; 77 | } 78 | $panels = FilamentModules::getModulePanels($this->argument('module')); 79 | if (empty($panels)) { 80 | $this->input->setOption('panel', $defaultPanel->getId()); 81 | 82 | return; 83 | } 84 | $options = collect([ 85 | $defaultPanel, 86 | ...$panels, 87 | ])->mapWithKeys(function (Panel | Cluster $panel) { 88 | return [$panel->getId() => $panel->getId()]; 89 | })->toArray(); 90 | 91 | $selectedPanel = select( 92 | 'Please select the panel to create the page in:', 93 | $options, 94 | default: $defaultPanel->getId(), 95 | ); 96 | 97 | if (! $selectedPanel) { 98 | $this->error('No panel selected. Aborting page creation.'); 99 | exit(1); 100 | } 101 | 102 | $this->input->setOption('panel', $selectedPanel); 103 | } 104 | } 105 | 106 | protected function configureCluster(): void 107 | { 108 | if ($this->hasResource) { 109 | $this->configureClusterFqn( 110 | initialQuestion: 'Is the resource in a cluster?', 111 | question: 'Which cluster is the resource in?', 112 | ); 113 | } else { 114 | $clusters = FilamentModules::getModuleClusters($this->argument('module')); 115 | if (empty($clusters)) { 116 | $this->clusterFqn = null; 117 | 118 | return; 119 | } 120 | if (confirm('Would you like to create the page in a cluster?', false)) { 121 | if (count($clusters) === 1) { 122 | $this->clusterFqn = Arr::first($clusters); 123 | // Show this to the user and ask to continue 124 | confirm("The page will be created in the cluster: {$this->clusterFqn}. Proceed?", true); 125 | } else { 126 | $this->clusterFqn = select( 127 | label: 'Please select the cluster to create the page in:', 128 | options: $clusters, 129 | default: $clusters[0], 130 | ); 131 | } 132 | } 133 | } 134 | 135 | if (blank($this->clusterFqn)) { 136 | return; 137 | } 138 | 139 | $this->configureClusterPagesLocation(); 140 | $this->configureClusterResourcesLocation(); 141 | } 142 | 143 | protected function configurePagesLocation(): void 144 | { 145 | if (filled($this->resourceFqn)) { 146 | return; 147 | } 148 | 149 | if (filled($this->clusterFqn)) { 150 | return; 151 | } 152 | 153 | if (! FilamentModules::getMode()->shouldRegisterPanels()) { 154 | $this->pagesNamespace = $this->getModule()->appNamespace('Filament\\Pages'); 155 | $this->pagesDirectory = $this->getModule()->appPath('Filament' . DIRECTORY_SEPARATOR . 'Pages' . DIRECTORY_SEPARATOR); 156 | 157 | return; 158 | } 159 | 160 | $panelModules = FilamentModules::getModulePanels($this->argument('module')); 161 | if (empty($panelModules) || ! collect($panelModules)->contains(fn ( 162 | Panel $panel 163 | ) => $panel->getId() === $this->panel->getId())) { 164 | $this->pagesNamespace = $this->getModule()->appNamespace('Filament\\Pages'); 165 | $this->pagesDirectory = $this->getModule()->appPath('Filament' . DIRECTORY_SEPARATOR . 'Pages' . DIRECTORY_SEPARATOR); 166 | 167 | return; 168 | } 169 | 170 | $directories = $this->panel->getPageDirectories(); 171 | $namespaces = $this->panel->getPageNamespaces(); 172 | 173 | foreach ($directories as $index => $directory) { 174 | if (str($directory)->startsWith(base_path('vendor'))) { 175 | unset($directories[$index]); 176 | unset($namespaces[$index]); 177 | } 178 | } 179 | 180 | if (count($namespaces) < 2) { 181 | $this->pagesNamespace = (Arr::first($namespaces) ?? $this->getModule()->appNamespace('Filament\\Pages')); 182 | $this->pagesDirectory = (Arr::first($directories) ?? $this->getModule()->appPath('Filament' . DIRECTORY_SEPARATOR . 'Pages' . DIRECTORY_SEPARATOR)); 183 | 184 | return; 185 | } 186 | 187 | $keyedNamespaces = array_combine( 188 | $namespaces, 189 | $namespaces, 190 | ); 191 | 192 | $this->pagesNamespace = search( 193 | label: 'Which namespace would you like to create this page in?', 194 | options: function (?string $search) use ($keyedNamespaces): array { 195 | if (blank($search)) { 196 | return $keyedNamespaces; 197 | } 198 | 199 | $search = str($search)->trim()->replace(['\\', '/'], ''); 200 | 201 | return array_filter( 202 | $keyedNamespaces, 203 | fn (string $namespace): bool => str($namespace)->replace(['\\', '/'], '')->contains( 204 | $search, 205 | ignoreCase: true 206 | ) 207 | ); 208 | }, 209 | ); 210 | $this->pagesDirectory = $directories[array_search($this->pagesNamespace, $namespaces)]; 211 | } 212 | 213 | protected function configureLocation(): void 214 | { 215 | $this->fqn = $this->pagesNamespace . '\\' . $this->fqnEnd; 216 | 217 | if ((! $this->hasResource) || ($this->resourcePageType === ResourcePage::class)) { 218 | $componentLocations = FilamentCli::getComponentLocations(); 219 | if (FilamentModules::getMode()->shouldRegisterPanels()) { 220 | $modelPanels = FilamentModules::getModulePanels($this->argument('module')); 221 | $modelPanel = collect($modelPanels)->first(fn (Panel | Cluster $panel) => $panel->getId() === $this->panel->getId()); 222 | } else { 223 | $modelPanel = null; 224 | } 225 | $pageComponent = FilamentModules::getModuleFilamentPageComponentLocation($this->getModule()->getName(), panelId: $modelPanel?->getId(), forCluster: (bool) $this->clusterFqn); 226 | $componentLocations[$pageComponent['namespace']] = $pageComponent; 227 | $matchingComponentLocationNamespaces = collect($componentLocations) 228 | ->keys() 229 | ->filter(fn (string $namespace): bool => str($this->fqn)->startsWith($namespace)); 230 | // Manually add this module's namespace there 231 | $v = str($this->fqn) 232 | ->whenContains( 233 | 'Filament\\', 234 | fn (Stringable $fqn) => $fqn->after('Filament\\')->prepend('Filament\\'), 235 | fn (Stringable $fqn) => $fqn->replaceFirst(app()->getNamespace(), ''), 236 | ) 237 | ->replace('\\', '/') 238 | ->explode('/') 239 | ->map(Str::kebab(...)) 240 | ->implode('.'); 241 | [ 242 | $this->view, 243 | $this->viewPath, 244 | ] = $this->askForViewLocation( 245 | view: $v, 246 | question: 'Where would you like to create the Blade view for the page?', 247 | defaultNamespace: (count($matchingComponentLocationNamespaces) === 1) 248 | ? $componentLocations[Arr::first($matchingComponentLocationNamespaces)]['viewNamespace'] ?? null 249 | : null, 250 | ); 251 | } 252 | } 253 | 254 | protected function getDefaultStubPath(): string 255 | { 256 | // I want to reuse filament's stubs where they are, without copying them to my app 257 | return base_path('vendor/filament/filament/stubs'); 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/ModulesServiceProvider.php: -------------------------------------------------------------------------------- 1 | name(static::$name) 31 | ->hasCommands($this->getCommands()) 32 | ->hasInstallCommand(function (InstallCommand $command) { 33 | $command 34 | ->publishConfigFile() 35 | ->endWith(function (InstallCommand $command) { 36 | $command->askToStarRepoOnGitHub('savannabits/filament-modules'); 37 | }); 38 | }); 39 | 40 | $configFileName = 'filament-modules'; 41 | 42 | if (file_exists($package->basePath("/../config/{$configFileName}.php"))) { 43 | $package->hasConfigFile($configFileName); 44 | } 45 | 46 | if (file_exists($package->basePath('/../database/migrations'))) { 47 | $package->hasMigrations($this->getMigrations()); 48 | } 49 | 50 | if (file_exists($package->basePath('/../resources/lang'))) { 51 | $package->hasTranslations(); 52 | } 53 | 54 | if (file_exists($package->basePath('/../resources/views'))) { 55 | $package->hasViews(static::$viewNamespace); 56 | } 57 | } 58 | 59 | public function packageRegistered(): void 60 | { 61 | $this->registerModuleMacros(); 62 | $this->autoDiscoverPanels(); 63 | } 64 | 65 | public function attemptToRegisterModuleProviders(): void 66 | { 67 | // It is necessary to register them here to avoid late registration (after Panels have already been booted) 68 | $pattern1 = config( 69 | 'modules.paths.modules', 70 | 'Modules' 71 | ) . '/*' . DIRECTORY_SEPARATOR . '*' . DIRECTORY_SEPARATOR . 'Providers' . DIRECTORY_SEPARATOR . '*Provider.php'; 72 | $pattern2 = config( 73 | 'modules.paths.modules', 74 | 'Modules' 75 | ) . '/*' . DIRECTORY_SEPARATOR . '*' . DIRECTORY_SEPARATOR . 'Providers' . DIRECTORY_SEPARATOR . 'Filament' . DIRECTORY_SEPARATOR . '*Provider.php'; 76 | $serviceProviders = glob($pattern1); 77 | $panelProviders = glob($pattern2); 78 | $providers = array_merge($serviceProviders, $panelProviders); 79 | 80 | foreach ($providers as $provider) { 81 | $namespace = FilamentModules::convertPathToNamespace($provider); 82 | $module = str($namespace)->before('\Providers\\')->afterLast('\\')->toString(); 83 | $className = str($namespace)->afterLast('\\')->toString(); 84 | if (str($className)->startsWith($module)) { 85 | // register the module service provider 86 | $this->app->register($namespace); 87 | } 88 | } 89 | } 90 | 91 | public function autoDiscoverPanels(): void 92 | { 93 | $this->app->beforeResolving('filament', function () { 94 | $modules = \Module::allEnabled(); 95 | $cacheKey = 'filament-modules-panel-providers'; 96 | $ttl = 10; // 24 hours 97 | $modules = \Module::allEnabled(); 98 | $panels = collect($modules)->flatMap(function (Module $module) { 99 | $panelProviders = glob($module->getExtraPath('app/Providers/Filament') . '/*.php'); 100 | 101 | return collect($panelProviders)->map(function ($path) { 102 | return $this->app[Modules::class]->convertPathToNamespace($path); 103 | })->toArray(); 104 | })->toArray(); 105 | foreach ($panels as $panel) { 106 | if (class_exists($panel)) { 107 | $this->app->register($panel); 108 | } 109 | } 110 | }); 111 | } 112 | 113 | public function packageBooted(): void 114 | { 115 | $this->attemptToRegisterModuleProviders(); 116 | // Asset Registration 117 | FilamentAsset::register( 118 | $this->getAssets(), 119 | $this->getAssetPackageName() 120 | ); 121 | 122 | FilamentAsset::registerScriptData( 123 | $this->getScriptData(), 124 | $this->getAssetPackageName() 125 | ); 126 | 127 | // Icon Registration 128 | FilamentIcon::register($this->getIcons()); 129 | 130 | // Handle Stubs 131 | if (app()->runningInConsole()) { 132 | foreach (app(Filesystem::class)->files(__DIR__ . '/../stubs/') as $file) { 133 | $this->publishes([ 134 | $file->getRealPath() => base_path("stubs/modules/{$file->getFilename()}"), 135 | ], 'modules-stubs'); 136 | } 137 | } 138 | 139 | // Testing 140 | Testable::mixin(new TestsModules); 141 | } 142 | 143 | protected function getAssetPackageName(): ?string 144 | { 145 | return 'coolsam/modules'; 146 | } 147 | 148 | /** 149 | * @return array 150 | */ 151 | protected function getAssets(): array 152 | { 153 | return []; 154 | } 155 | 156 | /** 157 | * @return array 158 | */ 159 | protected function getCommands(): array 160 | { 161 | return [ 162 | Commands\ModuleFilamentInstallCommand::class, 163 | Commands\ModuleMakeFilamentClusterCommand::class, 164 | Commands\ModuleMakeFilamentPluginCommand::class, 165 | Commands\ModuleMakeFilamentResourceCommand::class, 166 | Commands\ModuleMakeFilamentPageCommand::class, 167 | Commands\ModuleMakeFilamentWidgetCommand::class, 168 | Commands\ModuleMakeFilamentThemeCommand::class, 169 | Commands\ModuleMakeFilamentPanelCommand::class, 170 | ]; 171 | } 172 | 173 | /** 174 | * @return array 175 | */ 176 | protected function getIcons(): array 177 | { 178 | return []; 179 | } 180 | 181 | /** 182 | * @return array 183 | */ 184 | protected function getRoutes(): array 185 | { 186 | return []; 187 | } 188 | 189 | /** 190 | * @return array 191 | */ 192 | protected function getScriptData(): array 193 | { 194 | return []; 195 | } 196 | 197 | /** 198 | * @return array 199 | */ 200 | protected function getMigrations(): array 201 | { 202 | return [ 203 | // 'create_modules_table', 204 | ]; 205 | } 206 | 207 | protected function registerModuleMacros(): void 208 | { 209 | Module::macro('namespace', function (?string $relativeNamespace = '') { 210 | $relativeNamespace = $relativeNamespace ?? ''; 211 | $base = trim($this->app['config']->get('modules.namespace', 'Modules'), '\\'); 212 | $relativeNamespace = trim($relativeNamespace, '\\'); 213 | $studlyName = $this->getStudlyName(); 214 | 215 | return str($base)->append('\\')->append($studlyName)->append('\\')->append($relativeNamespace)->replace('\\\\', '\\')->toString(); 216 | }); 217 | 218 | Module::macro('getTitle', function () { 219 | return str($this->getStudlyName())->kebab()->title()->replace('-', ' ')->toString(); 220 | }); 221 | 222 | Module::macro('appNamespace', function (string $relativeNamespace = '') { 223 | $prefix = str(config('modules.paths.app_folder', 'app'))->ltrim(DIRECTORY_SEPARATOR, '\\')->studly()->toString(); 224 | $relativeNamespace = trim($relativeNamespace, '\\'); 225 | if (filled($prefix)) { 226 | $relativeNamespace = str_replace($prefix . '\\', '', $relativeNamespace); 227 | $relativeNamespace = str_replace($prefix, '', $relativeNamespace); 228 | } 229 | 230 | return $this->namespace($relativeNamespace); 231 | }); 232 | Module::macro('appPath', function (string $relativePath = '') { 233 | $appPath = $this->getExtraPath(config('modules.paths.app_folder', 'app')); 234 | 235 | return str($appPath . ($relativePath ? DIRECTORY_SEPARATOR . $relativePath : ''))->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR)->toString(); 236 | }); 237 | 238 | Module::macro('databasePath', function (string $relativePath = '') { 239 | $appPath = $this->getExtraPath('database'); 240 | 241 | return str($appPath . ($relativePath ? DIRECTORY_SEPARATOR . $relativePath : ''))->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR)->toString(); 242 | }); 243 | 244 | Module::macro('resourcesPath', function (string $relativePath = '') { 245 | $appPath = $this->getExtraPath('resources'); 246 | 247 | return str($appPath . ($relativePath ? DIRECTORY_SEPARATOR . $relativePath : '')) 248 | ->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR)->toString(); 249 | }); 250 | 251 | Module::macro('migrationsPath', function (string $relativePath = '') { 252 | $appPath = $this->databasePath('migrations'); 253 | 254 | return str($appPath . ($relativePath ? DIRECTORY_SEPARATOR . $relativePath : '')) 255 | ->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR)->toString(); 256 | }); 257 | 258 | Module::macro('seedersPath', function (string $relativePath = '') { 259 | $appPath = $this->databasePath('seeders'); 260 | 261 | return str($appPath . ($relativePath ? DIRECTORY_SEPARATOR . $relativePath : ''))->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR)->toString(); 262 | }); 263 | 264 | Module::macro('factoriesPath', function (string $relativePath = '') { 265 | $appPath = $this->databasePath('factories'); 266 | 267 | return str($appPath . ($relativePath ? DIRECTORY_SEPARATOR . $relativePath : ''))->replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR)->toString(); 268 | }); 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Filament Modules v5.x 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/coolsam/modules.svg?style=flat-square)](https://packagist.org/packages/coolsam/modules) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/savannabits/filament-modules/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/savannabits/filament-modules/actions?query=workflow%3Arun-tests+branch%3Amain) 5 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/savannabits/filament-modules/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/savannabits/filament-modules/actions?query=workflow%3Afix-php-code-style+branch%3Amain) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/coolsam/modules.svg?style=flat-square)](https://packagist.org/packages/coolsam/modules) 7 | 8 | > **NOTE:** This documentation is for **version 5.x** of the package, which supports **Laravel 11+**, **Filament 4.x** 9 | > and 10 | **nwidart/laravel-modules 11+**. If you are using Filament 3.x, please refer 11 | > to [4.x documentation](https://github.com/savannabits/filament-modules/tree/4.x) 12 | > or [3.x documentation](https://github.com/savannabits/filament-modules/tree/3.x) if you are using Laravel 10. 13 | 14 | ![image](https://github.com/savannabits/filament-modules/assets/5610289/ba191f1d-b5ee-4eb9-9db7-d42a19cc8d38) 15 | 16 | This package brings the power of modules to Laravel Filament. It allows you to organize your filament code into fully 17 | autonomous modules that can be easily shared and reused across multiple projects. 18 | With this package, you can turn each of your modules into a fully functional Filament Plugin with its own resources, 19 | pages, widgets, components and more. What's more, you don't even need to register each of these plugins in your main 20 | Filament Panel. All you need to do is register the `ModulesPlugin` in your panel, and it will take care of the rest for 21 | you. 22 | 23 | This package is simple a wrapper of [nwidart/laravel-modules](https://docs.laravelmodules.com) package to make it work 24 | with Laravel Filament. 25 | 26 | ## Features 27 | 28 | - A command to prepare your module for Filament 29 | - A command to create a Filament Cluster in your module 30 | - A command to create additional Filament Plugins in your module 31 | - A command to create a new Filament resource in your module 32 | - A command to create a new Filament page in your module 33 | - A command to create a new Filament widget in your module 34 | - Organize your admin panel into Cluster, one for each supported module. 35 | 36 | ## Requirements 37 | 38 | The following is a table showing a matrix of supported filament and laravel versions for each version of this package: 39 | 40 | | Package Version | Laravel Version | Filament Version | nwidart/laravel-modules Version | 41 | |-----------------|-----------------|------------------|---------------------------------| 42 | | 5.x | 11.x and 12.x | 4.x | 11.x or 12.x | 43 | | 4.x | 11.x and 12.x | 3.x | 11.x or 12.x | 44 | | 3.x | 10.x | 3.x | 11.x | 45 | 46 | v5.x of this package requires the following dependencies: 47 | 48 | - Laravel 11.x or 12.x 49 | - Filament 4.x or higher 50 | - PHP 8.2 or higher 51 | - nwidart/laravel-modules 11.x or 12.x 52 | 53 | ## Installation 54 | 55 | You can install the package via composer: 56 | 57 | ```bash 58 | composer require coolsam/modules 59 | ``` 60 | 61 | This will automatically install `nwidart/laravel-modules: ^11` (for Laravel 11) or `nwidart/laravel-modules: ^12` (for 62 | Laravel 12) as well. Make sure you go through 63 | the [documentation](https://laravelmodules.com/docs/12) to understand how to use the package and to configure it 64 | properly before proceeding. 65 | 66 | **Task: Configure your Laravel Modules first before continuing.** 67 | 68 | ### Autoloading modules 69 | 70 | Don't forget to autoload modules by adding the merge-plugin to your composer.json according to the [laravel modules documentation](https://laravelmodules.com/docs/12/getting-started/installation-and-setup#autoloading): 71 | 72 | ```json 73 | "extra": { 74 | "laravel": { 75 | "dont-discover": [] 76 | }, 77 | "merge-plugin": { 78 | "include": [ 79 | "Modules/*/composer.json" 80 | ] 81 | } 82 | }, 83 | ``` 84 | 85 | Next, Run the installation command and follow the prompts to publish the config file and set up the package: 86 | 87 | ```bash 88 | php artisan modules:install 89 | ``` 90 | 91 | Alternatively, you can just publish the config file with: 92 | 93 | ```bash 94 | php artisan vendor:publish --tag="modules-config" 95 | ``` 96 | 97 | ### Configuration 98 | 99 | After publishing the config file, you can configure the package to your liking. The configuration file is located 100 | at `config/filament-modules.php`. 101 | 102 | The following can be adjusted in the configuration file: 103 | 104 | - **mode**: The mode used by the package to discover and register resources from modules. This can be set to plugins, 105 | panels or both (default). See the ConfigMode enum for details. 106 | - **auto-register-plugins**: If set to true, the package will automatically register all the plugins in your modules. 107 | Otherwise, you will need to register each plugin manually in your Filament Panel. 108 | - **clusters.enabled**: If set to true, a cluster will be created in each module during the `module:filament:install` 109 | command and all filament files for that module may reside inside that cluster. Otherwise, filament files will reside 110 | in Filament/Resources, Filament/Pages, Filament/Widgets, etc. 111 | - **clusters.use-top-navigation**: If set to true, the top navigation will be used to navigate between clusters while 112 | the actual links will be loaded as a side sub-navigation. In my opinion, this improves UX. Otherwise, the package will 113 | honor the configuration that you have in your panel. 114 | - **panels.group**: The group under which the panels will be registered in the Main Panel's navigation. This is only 115 | applicable if the mode is set to support panels. All links to the various module panels will be grouped under this 116 | group in the main panel's navigation for ease of navigation. 117 | - **panels.group-icon**: The group icon used in the above navigation. This is only applicable if the mode is set to 118 | support panels. 119 | - **panels.open-in-new-tab**: If set to true, the links to the module panels will open in a new tab. This is only 120 | applicable if the mode is set to support panels. 121 | - **panels.group-sort**: The sort order applied on each navigation item in the modules panel group. 122 | 123 | ## Usage 124 | 125 | ### Register the plugin 126 | 127 | The package comes with a `ModulesPlugin` that you can register in your Filament Panel. This plugin will automatically 128 | load all the modules in your application and register them as Filament plugins if the mode supports plugins (either 129 | PLUGINS or BOTH). 130 | If the configuration mode supports panels, the module is also responsible for automatically creating navigation links 131 | between the main panel and each of the module panels. 132 | In order to achieve this, you need to register the `ModulesPlugin` in your panel of choice (e.g. Admin Panel) like so: 133 | 134 | ```php 135 | // e.g. in App\Providers\Filament\AdminPanelProvider.php 136 | 137 | use Coolsam\Modules\ModulesPlugin; 138 | public function panel(Panel $panel): Panel 139 | { 140 | return $panel 141 | ... 142 | ->plugin(ModulesPlugin::make()); 143 | } 144 | ``` 145 | 146 | That's it! now you are ready to start creating some filament code in your module of choice! 147 | 148 | ### Installing Filament in a module 149 | 150 | If you don't have a module already, you can generate one using the `module:make` command like so: 151 | 152 | ```bash 153 | php artisan module:make MyModule 154 | ``` 155 | 156 | Next, run the `module:filament:install` command to generate the necessary Filament files and directories in your module: 157 | 158 | ```bash 159 | php artisan module:filament:install MyModule 160 | ``` 161 | 162 | This will guide you interactively on whether you want to organize your code in clusters, and whether you would like to 163 | create a default cluster. 164 | At the end of this installation, you will have the following structure in your module: 165 | 166 | - Modules 167 | - MyModule 168 | - app 169 | - Filament 170 | - Clusters 171 | - MyModule 172 | - Pages 173 | - Resources 174 | - Widgets 175 | - **MyModule.php** 176 | - Pages 177 | - Resources 178 | - Widgets 179 | - OneModulePanel 180 | - Pages 181 | - Resources 182 | - Widgets 183 | - AnotherModulePanel 184 | - Pages 185 | - Resources 186 | - Widgets 187 | - **MyModulePlugin.php** 188 | - Providers 189 | - Filament 190 | - OneModulePanelServiceProvider.php 191 | - AnotherModulePanelServiceProvider.php 192 | - **MyModuleServiceProvider.php** 193 | 194 | As you can see, there are two main files generated: The plugin class and optionally the cluster class. After generation, 195 | you are free to make any modifications to these classes as you may see fit. Optionally, Panels are also generated inside the module if supported. 196 | All these can be generated individually later using their respective commands. 197 | 198 | The **plugin** will be loaded automatically unless the configuration is set otherwise. As a result, it will also load 199 | all its clusters automatically. 200 | 201 | Panels will register their navigation links in the main panel's navigation if the configuration is set to support panels. In the individual panels, there will also 202 | be a link to the main panel's navigation, allowing you to navigate back to the main panel. 203 | 204 | Your module is now ready to be used in your Filament Panel. Use the following commands during development to generate 205 | new resources, pages, widgets, plugins, panels and clusters in your module: 206 | 207 | ### Creating a new resource 208 | 209 | ```bash 210 | php artisan module:make:filament-resource 211 | # Aliases 212 | php artisan module:filament:resource 213 | php artisan module:filament:make-resource 214 | ``` 215 | 216 | Follow the interactive prompts to create a new resource in your module. 217 | 218 | ### Creating a new page 219 | 220 | ```bash 221 | php artisan module:make:filament-page 222 | # or 223 | php artisan module:filament:page 224 | php artisan module:filament:make-page 225 | ``` 226 | 227 | Follow the interactive prompts to create a new page in your module. 228 | 229 | ### Creating a new widget (WIP) 230 | 231 | ```bash 232 | php artisan module:make:filament-widget 233 | # or 234 | php artisan module:filament:widget 235 | php artisan module:filament:make-widget 236 | ``` 237 | 238 | Follow the interactive prompts to create a new widget in your module. 239 | 240 | ### Creating a new cluster 241 | 242 | ```bash 243 | php artisan module:make:filament-cluster 244 | # or 245 | php artisan module:filament:cluster 246 | php artisan module:filament:make-cluster 247 | ``` 248 | 249 | Follow the interactive prompts to create a new cluster in your module. 250 | 251 | ### Creating a new plugin 252 | 253 | ```bash 254 | php artisan module:make:filament-plugin 255 | # or 256 | php artisan module:filament:plugin 257 | php artisan module:filament:make-plugin 258 | ``` 259 | 260 | Follow the interactive prompts to create a new plugin in your module. 261 | 262 | ### Create a new Filament Theme (WIP) 263 | 264 | ```bash 265 | php artisan module:make:filament-theme 266 | # or 267 | php artisan module:filament:theme 268 | php artisan module:filament:make-theme 269 | ``` 270 | 271 | ### Create a new Filament Panel (New!!) 272 | 273 | ```bash 274 | php artisan module:make:filament-panel 275 | # or 276 | php artisan module:filament:panel 277 | php artisan module:filament:make-panel 278 | ``` 279 | Follow the interactive prompts to create a new panel in your module. 280 | 281 | 282 | ### Protecting your resources, pages and widgets (Access Control) - WIP 283 | 284 | ```php 285 | use Coolsam\Modules\Resource; 286 | ``` 287 | 288 | use the above resource class instead of `use Filament/Resources/Resource;` into your resource class file to protect your 289 | resources. 290 | 291 | ```php 292 | use Coolsam\Modules\Page; 293 | ``` 294 | 295 | use the above page class instead of `use Filament/Pages/Page;` into your page class file to protect your pages. 296 | 297 | ```php 298 | use Coolsam\Modules\TableWidget; 299 | ``` 300 | 301 | use the above page class instead of `use Filament/Pages/TableWidget;` into your widget class file to protect your 302 | TableWidget. 303 | 304 | ```php 305 | use Coolsam\Modules\ChartWidget; 306 | ``` 307 | 308 | use the above page class instead of `use Filament/Pages/ChartWidget;` into your widget class file to protect your 309 | ChartWidget. 310 | 311 | ```php 312 | use Coolsam\Modules\StatsOverviewWidget; 313 | ``` 314 | 315 | use the above page class instead of `use Filament/Pages/StatsOverviewWidget;` into your widget class file to protect 316 | your StatsOverviewWidget. 317 | 318 | ```php 319 | use CanAccessTrait; 320 | ``` 321 | 322 | use the above trait directly into your resource and page class file to protect your resources and pages. 323 | 324 | ## Testing 325 | 326 | ```bash 327 | composer test 328 | ``` 329 | 330 | ## Changelog 331 | 332 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 333 | 334 | ## Contributing 335 | 336 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. 337 | 338 | ## Security Vulnerabilities 339 | 340 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 341 | 342 | ## Credits 343 | 344 | - [Sam Maosa](https://github.com/coolsam726) 345 | - [All Contributors](../../contributors) 346 | 347 | ## License 348 | 349 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 350 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `modules` will be documented in this file. 4 | 5 | ## v5.0.9 - 2025-12-16 6 | 7 | ### What's Changed 8 | 9 | * Refactor ModuleMakeFilamentThemeCommand to inject Filesystem dependen… by @nicolawebdev in https://github.com/savannabits/filament-modules/pull/159 10 | 11 | ### New Contributors 12 | 13 | * @nicolawebdev made their first contribution in https://github.com/savannabits/filament-modules/pull/159 14 | 15 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v5.0.8...v5.0.9 16 | 17 | ## v5.0.8 - 2025-09-10 18 | 19 | ### What's Changed 20 | 21 | * Initial Work: Filament Widget Generation by @coolsam726 in https://github.com/savannabits/filament-modules/pull/153 22 | 23 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v5.0.7...v5.0.8 24 | 25 | ## v5.0.7 - 2025-09-08 26 | 27 | [Bug Fix: Wrong Path appFilament when generating resources with the default config](https://github.com/savannabits/filament-modules/commit/eabc1e6c2c1679191756a5d0ec26d19c13011a29) 28 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v5.0.6...v5.0.7 29 | 30 | ## v5.0.6 - 2025-09-03 31 | 32 | ### What's Changed 33 | 34 | * Bug Fix: rtrim was erroneously removing s in Models by @coolsam726 in https://github.com/savannabits/filament-modules/pull/148 35 | 36 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v5.0.5...v5.0.6 37 | 38 | ## v5.0.5 - 2025-09-03 39 | 40 | ### What's Changed 41 | 42 | * Fix: read app_folder variable from laravel-modules by @piotrczech in https://github.com/savannabits/filament-modules/pull/146 43 | * Feature: Support Generation of Filament classes for custom Module classes by @coolsam726 in https://github.com/savannabits/filament-modules/pull/147 44 | 45 | ### New Contributors 46 | 47 | * @piotrczech made their first contribution in https://github.com/savannabits/filament-modules/pull/146 48 | 49 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v5.0.4...v5.0.5 50 | 51 | ## v5.0.4 - 2025-09-03 52 | 53 | ### What's Changed 54 | 55 | * Hotfix dynamic app folder by @coolsam726 in https://github.com/savannabits/filament-modules/pull/144 56 | 57 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v5.0.3...v5.0.4 58 | 59 | ## v4.2.2 - 2025-09-03 60 | 61 | ### What's Changed 62 | 63 | * Hotfix dynamic app folder by @coolsam726 in https://github.com/savannabits/filament-modules/pull/144 64 | 65 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v5.0.3...v4.2.2 66 | 67 | ## v5.0.3 - 2025-09-03 68 | 69 | ### What's Changed 70 | 71 | * Hotfix dynamic app folder by @coolsam726 in https://github.com/savannabits/filament-modules/pull/142 72 | 73 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v5.0.2...v5.0.3 74 | 75 | ## v4.2.1 - 2025-09-03 76 | 77 | ### What's Changed 78 | 79 | * Bump dependabot/fetch-metadata from 2.3.0 to 2.4.0 by @dependabot[bot] in https://github.com/savannabits/filament-modules/pull/128 80 | * Bump aglipanci/laravel-pint-action from 2.5 to 2.6 by @dependabot[bot] in https://github.com/savannabits/filament-modules/pull/132 81 | * Hotfix dynamic app folder by @coolsam726 in https://github.com/savannabits/filament-modules/pull/143 82 | 83 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v4.2.0...v4.2.1 84 | 85 | ## v5.0.2 - 2025-08-31 86 | 87 | ### What's Changed 88 | 89 | * Fix: Support nwidart/laravel-modules v11 by @coolsam726 in https://github.com/savannabits/filament-modules/pull/141 90 | 91 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v5.0.1...v5.0.2 92 | 93 | ## v5.0.1 - 2025-08-22 94 | 95 | ### What's Changed 96 | 97 | * Bump actions/checkout from 4 to 5 by @dependabot[bot] in https://github.com/savannabits/filament-modules/pull/133 98 | * Bump stefanzweifel/git-auto-commit-action from 5 to 6 by @dependabot[bot] in https://github.com/savannabits/filament-modules/pull/130 99 | * Panel Generation and Registration Improvements: by @coolsam726 in https://github.com/savannabits/filament-modules/pull/139 100 | 101 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v5.0.0...v5.0.1 102 | 103 | ## v5.0.0 - Support for Filament v4 - 2025-08-21 104 | 105 | ### What's Changed 106 | 107 | * Bump dependabot/fetch-metadata from 2.3.0 to 2.4.0 by @dependabot[bot] in https://github.com/savannabits/filament-modules/pull/128 108 | * Bump aglipanci/laravel-pint-action from 2.5 to 2.6 by @dependabot[bot] in https://github.com/savannabits/filament-modules/pull/132 109 | * 5.x Updates by @coolsam726 in https://github.com/savannabits/filament-modules/pull/135 110 | * 5.x - Docs and Tests Fixes by @coolsam726 in https://github.com/savannabits/filament-modules/pull/136 111 | 112 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v4.2.0...v5.0.0 113 | 114 | ## v4.2.0 - 2025-03-19 115 | 116 | ### What's Changed 117 | 118 | * Update composer.json by @cjango in https://github.com/savannabits/filament-modules/pull/123 119 | * Add new widget classes and update README for widgets protection by @aisuvro in https://github.com/savannabits/filament-modules/pull/122 120 | * Added Laravel Modules 12 support by @coolsam726 in https://github.com/savannabits/filament-modules/pull/125 121 | 122 | ### New Contributors 123 | 124 | * @cjango made their first contribution in https://github.com/savannabits/filament-modules/pull/123 125 | 126 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v4.1.0...v4.2.0 127 | 128 | ## v4.1.2 - 2025-03-19 129 | 130 | ### What's Changed 131 | 132 | * Update composer.json by @cjango in https://github.com/savannabits/filament-modules/pull/123 133 | * Add new widget classes and update README for widgets protection by @aisuvro in https://github.com/savannabits/filament-modules/pull/122 134 | * Added Laravel Modules 12 support by @coolsam726 in https://github.com/savannabits/filament-modules/pull/125 135 | 136 | ### New Contributors 137 | 138 | * @cjango made their first contribution in https://github.com/savannabits/filament-modules/pull/123 139 | 140 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v4.1.0...v4.1.2 141 | 142 | ## v4.1.0 - 2025-02-24 143 | 144 | ### What's Changed 145 | 146 | * Bump dependabot/fetch-metadata from 1.6.0 to 2.1.0 by @dependabot in https://github.com/savannabits/filament-modules/pull/104 147 | * Bump dependabot/fetch-metadata from 2.1.0 to 2.2.0 by @dependabot in https://github.com/savannabits/filament-modules/pull/108 148 | * changing from Filament\Plugin to Coolsam\Modules in ReadMe.md by @dedanirungu in https://github.com/savannabits/filament-modules/pull/110 149 | * Bump dependabot/fetch-metadata from 2.2.0 to 2.3.0 by @dependabot in https://github.com/savannabits/filament-modules/pull/119 150 | * Bump aglipanci/laravel-pint-action from 2.4 to 2.5 by @dependabot in https://github.com/savannabits/filament-modules/pull/120 151 | * [style] fix install command on readme file by @alissn in https://github.com/savannabits/filament-modules/pull/112 152 | * [Bug fix] implement the resource and page security by @aisuvro in https://github.com/savannabits/filament-modules/pull/118 153 | * Only Discover resources/widgets ...etc if module enabled by @Saifallak in https://github.com/savannabits/filament-modules/pull/121 154 | 155 | ### New Contributors 156 | 157 | * @dedanirungu made their first contribution in https://github.com/savannabits/filament-modules/pull/110 158 | * @alissn made their first contribution in https://github.com/savannabits/filament-modules/pull/112 159 | * @aisuvro made their first contribution in https://github.com/savannabits/filament-modules/pull/118 160 | * @Saifallak made their first contribution in https://github.com/savannabits/filament-modules/pull/121 161 | 162 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v4.0.6...v4.1.0 163 | 164 | ## v4.0.6 - 2024-04-24 165 | 166 | ### What's Changed 167 | 168 | * Refactor: Replace '/' with DIRECTORY_SEPARATOR in the File generation Concerns by @coolsam726 in https://github.com/savannabits/filament-modules/pull/103 169 | 170 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v4.0.5...v4.0.6 171 | 172 | ## v4.0.5 - 2024-04-24 173 | 174 | ### What's Changed 175 | 176 | * Failing Tests: Changed the test to use DIRECTORY_SEPARATOR by @coolsam726 in https://github.com/savannabits/filament-modules/pull/99 177 | * Code Cleanup: PHPStan was failing by @coolsam726 in https://github.com/savannabits/filament-modules/pull/100 178 | * Bug Fix: Duplicate src in file path by @coolsam726 in https://github.com/savannabits/filament-modules/pull/102 179 | 180 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v4.0.4...v4.0.5 181 | 182 | ## v4.0.4 - 2024-04-20 183 | 184 | ### What's Changed 185 | 186 | * Bug Fix: Wrong Directory Separators causing errors when registering Module Plugins by @coolsam726 in https://github.com/savannabits/filament-modules/pull/98 187 | 188 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v4.0.3...v4.0.4 189 | 190 | ## v4.0.3 - 2024-04-19 191 | 192 | ### What's Changed 193 | 194 | * New Test: Testing the convertPathToNamespace helper method by @coolsam726 in https://github.com/savannabits/filament-modules/pull/93 195 | * Attempt 1 to fix the `convertPathToNamespace` helper by @coolsam726 in https://github.com/savannabits/filament-modules/pull/94 196 | * Attempt 2: Test if convertPathToNamespace is fixed by @coolsam726 in https://github.com/savannabits/filament-modules/pull/95 197 | * Attempt 3: To Fix convertPathToNamespace for windows platform by @coolsam726 in https://github.com/savannabits/filament-modules/pull/96 198 | * Bug Fix: Registering Module Service Providers was not working on windows by @coolsam726 in https://github.com/savannabits/filament-modules/pull/97 - Fixes #92 199 | 200 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v4.0.2...v4.0.3 201 | 202 | ## v4.0.2 - 2024-04-16 203 | 204 | ### What's Changed 205 | 206 | * Early Registration of the Main providers for each module. by @coolsam726 in https://github.com/savannabits/filament-modules/pull/91 207 | 208 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v4.0.1...v4.0.2 209 | 210 | ## v4.0.1 - 2024-04-15 211 | 212 | ### What's Changed 213 | 214 | * New Feature: A command to generate a Theme inside a module by @coolsam726 in https://github.com/savannabits/filament-modules/pull/89 215 | * README: Added the Filament Theme command documentation by @coolsam726 in https://github.com/savannabits/filament-modules/pull/90 216 | 217 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v4.0.0...v4.0.1 218 | 219 | ## v4.0.0 - 2024-04-15 220 | 221 | ### What's Changed 222 | 223 | * 4.x dev - Support for nwidart/laravel-modules 11.x by @coolsam726 in https://github.com/savannabits/filament-modules/pull/82 224 | * Added Version 4.x Documentation by @coolsam726 in https://github.com/savannabits/filament-modules/pull/84 225 | * README Adjustment: Put the version NOTE below the badges by @coolsam726 in https://github.com/savannabits/filament-modules/pull/86 226 | * support laravel-modules v11 for laravel 11 by @vellea in https://github.com/savannabits/filament-modules/pull/81 227 | * Fixed Tests by @coolsam726 in https://github.com/savannabits/filament-modules/pull/87 228 | * Fix PHP version in phpstan workflow by @coolsam726 in https://github.com/savannabits/filament-modules/pull/88 229 | 230 | ### New Contributors 231 | 232 | * @vellea made their first contribution in https://github.com/savannabits/filament-modules/pull/81 233 | 234 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v3.0.1...v4.0.0 235 | 236 | ## v3.0.1 - 2024-04-15 237 | 238 | ### What's Changed 239 | 240 | * Bug Fix: navigation label translation key during filament cluster generation by @coolsam726 in https://github.com/savannabits/filament-modules/pull/83 241 | 242 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v3.0.0...v3.0.1 243 | 244 | ## v3.0.0 - 2024-04-14 245 | 246 | ### What's Changed 247 | 248 | * Support for Filament Shield and Module Permissions (WIP) by @coolsam726 in https://github.com/savannabits/filament-modules/pull/33 249 | * 3.x dev by @coolsam726 in https://github.com/savannabits/filament-modules/pull/39 250 | * Package Name change from filament-modules to modules by @coolsam726 in https://github.com/savannabits/filament-modules/pull/40 251 | * Composer requirements: by @coolsam726 in https://github.com/savannabits/filament-modules/pull/41 252 | * Added sidebar start and end hooks for modular panels by @coolsam726 in https://github.com/savannabits/filament-modules/pull/42 253 | * Fix: Moved sidebar hooks to the register function after filament has been resolved. by @coolsam726 in https://github.com/savannabits/filament-modules/pull/43 254 | * 3.x dev - Updated the Sidebar Render Hooks by @coolsam726 in https://github.com/savannabits/filament-modules/pull/44 255 | * Bump aglipanci/laravel-pint-action from 2.3.0 to 2.3.1 by @dependabot in https://github.com/savannabits/filament-modules/pull/60 256 | * Laravel 11 Support by @askippers in https://github.com/savannabits/filament-modules/pull/58 257 | * Bump dependabot/fetch-metadata from 1.6.0 to 2.0.0 by @dependabot in https://github.com/savannabits/filament-modules/pull/59 258 | * Bump aglipanci/laravel-pint-action from 2.3.1 to 2.4 by @dependabot in https://github.com/savannabits/filament-modules/pull/64 259 | * Package rewrite to for v3 by @coolsam726 in https://github.com/savannabits/filament-modules/pull/65 260 | * Adjusted README and run-tests workflows to fix the repository link by @coolsam726 in https://github.com/savannabits/filament-modules/pull/68 261 | * Adjusted the run-tests workflow to capture the testbench and carbon matrix by @coolsam726 in https://github.com/savannabits/filament-modules/pull/69 262 | * Separated Tests for Laravel 10 and Laravel 11 by @coolsam726 in https://github.com/savannabits/filament-modules/pull/70 263 | * Added support for nunomaduro/collision v8.x by @coolsam726 in https://github.com/savannabits/filament-modules/pull/71 264 | * README fix: Changed the name of the workflow for the code styling workflow by @coolsam726 in https://github.com/savannabits/filament-modules/pull/72 265 | * New Feature added: Command to create a Filament Resource inside a module by @coolsam726 in https://github.com/savannabits/filament-modules/pull/73 266 | * New Feature: Command to Create Filament Pages in Modules by @coolsam726 in https://github.com/savannabits/filament-modules/pull/74 267 | * Make the Cluster Navigation Label translatable by @coolsam726 in https://github.com/savannabits/filament-modules/pull/75 268 | * New Feature: Widget Generation Command by @coolsam726 in https://github.com/savannabits/filament-modules/pull/76 269 | * Bug Fix: README Badges - fixed the workflow file name by @coolsam726 in https://github.com/savannabits/filament-modules/pull/77 270 | * Updated README with the package's documentation for v3.x by @coolsam726 in https://github.com/savannabits/filament-modules/pull/78 271 | * Update README.md by @coolsam726 in https://github.com/savannabits/filament-modules/pull/79 272 | 273 | ### New Contributors 274 | 275 | * @askippers made their first contribution in https://github.com/savannabits/filament-modules/pull/58 276 | 277 | **Full Changelog**: https://github.com/savannabits/filament-modules/compare/v1.3.3...v3.0.0 278 | 279 | ## 1.0.0 - 202X-XX-XX 280 | 281 | - initial release 282 | --------------------------------------------------------------------------------