diff --git a/packages/php/README.md b/packages/php/README.md index cc2bd6888..32d569199 100644 --- a/packages/php/README.md +++ b/packages/php/README.md @@ -11,7 +11,28 @@ Nx is a build system, optimized for monorepos, with plugins for popular frameworks and tools and advanced CI capabilities including caching and distribution. -This package is a PHP plugin for Nx. +This package is a PHP plugin for Nx with support for Composer, PHPUnit, and Laravel projects. + +## Features + +- **Composer Plugin**: Automatically detects `composer.json` files and creates targets for: + - Installing dependencies (`install`) + - Updating dependencies (`update`) + - Running custom composer scripts + - Building dependency graph for PHP packages + +- **PHPUnit Plugin**: Automatically detects `phpunit.xml` files and creates targets for: + - Running tests (`test`) + - With proper caching and dependency management + +- **Laravel Plugin**: Automatically detects Laravel projects (via `artisan` file) and creates targets for: + - Serving the application (`serve`) + - Running migrations (`migrate`, `migrate:fresh`) + - Laravel Tinker REPL (`tinker`) + - Queue workers (`queue:work`) + - Cache management (`cache:clear`) + - Route listing (`route:list`) + - Custom artisan commands from composer.json ## Getting Started @@ -56,8 +77,45 @@ nx run-many -t update # run tests for all projects nx run-many -t test + +# Laravel-specific commands +# serve all Laravel apps +nx run-many -t serve + +# run migrations for all Laravel apps +nx run-many -t migrate + +# clear caches for all Laravel apps +nx run-many -t cache:clear +``` + +## Laravel Plugin Usage + +The Laravel plugin automatically detects Laravel projects by looking for the `artisan` file and verifying the presence of Laravel framework in `composer.json`. Once detected, it creates the following targets: + +```bash +# Development server +nx serve my-laravel-app + +# Database migrations +nx migrate my-laravel-app +nx migrate:fresh my-laravel-app # Fresh migration with seeding + +# Laravel REPL +nx tinker my-laravel-app + +# Queue management +nx queue:work my-laravel-app + +# Cache management +nx cache:clear my-laravel-app + +# Routes +nx route:list my-laravel-app ``` +The plugin also detects and exposes any custom artisan commands defined in your `composer.json` scripts section. + ## Documentation & Resources - [Nx.Dev: Documentation, Guides, Tutorials](https://nx.dev) diff --git a/packages/php/package.json b/packages/php/package.json index ce5434a6a..9026a5466 100644 --- a/packages/php/package.json +++ b/packages/php/package.json @@ -14,6 +14,7 @@ "./package.json": "./package.json", "./composer": "./src/composer/index.js", "./generators": "./src/generators/index.js", - "./phpunit": "./src/phpunit/index.js" + "./phpunit": "./src/phpunit/index.js", + "./laravel": "./src/laravel/index.js" } } diff --git a/packages/php/project.json b/packages/php/project.json index 998dc2683..9e7d77631 100644 --- a/packages/php/project.json +++ b/packages/php/project.json @@ -24,22 +24,22 @@ "assets": [ "packages/php/*.md", { - "input": "./packages/php/src", + "input": "packages/php/src", "glob": "**/!(*.ts)", "output": "./src" }, { - "input": "./packages/php/src", + "input": "packages/php/src", "glob": "**/*.d.ts", "output": "./src" }, { - "input": "./packages/php", + "input": "packages/php", "glob": "generators.json", "output": "." }, { - "input": "./packages/php", + "input": "packages/php", "glob": "executors.json", "output": "." } diff --git a/packages/php/src/generators/init/init.ts b/packages/php/src/generators/init/init.ts index 531008ea8..111e7e9d6 100644 --- a/packages/php/src/generators/init/init.ts +++ b/packages/php/src/generators/init/init.ts @@ -9,6 +9,7 @@ import { import { version } from '../../../package.json'; import { addComposerPlugin } from './lib/add-composer-plugin'; import { addPhpunitPlugin } from './lib/add-phpunit-plugin'; +import { addLaravelPlugin } from './lib/add-laravel-plugin'; import { InitGeneratorSchema } from './schema'; export async function initGenerator(tree: Tree, options: InitGeneratorSchema) { @@ -37,6 +38,7 @@ export async function initGenerator(tree: Tree, options: InitGeneratorSchema) { } await addComposerPlugin(tree, options); await addPhpunitPlugin(tree, options); + await addLaravelPlugin(tree, options); updateNxJsonConfiguration(tree); diff --git a/packages/php/src/generators/init/lib/add-laravel-plugin.ts b/packages/php/src/generators/init/lib/add-laravel-plugin.ts new file mode 100644 index 000000000..d814ddb35 --- /dev/null +++ b/packages/php/src/generators/init/lib/add-laravel-plugin.ts @@ -0,0 +1,25 @@ +import { createProjectGraphAsync, type Tree } from '@nx/devkit'; +import { addPlugin } from '@nx/devkit/src/utils/add-plugin'; +import { createNodesV2 } from '../../../laravel'; + +export async function addLaravelPlugin( + tree: Tree, + _options: { skipPackageJson?: boolean } +): Promise { + await addPlugin( + tree, + await createProjectGraphAsync(), + '@nx/php/laravel', + createNodesV2, + { + serveTargetName: ['serve'], + migrateTargetName: ['migrate'], + migrateFreshTargetName: ['migrate-fresh'], + tinkerTargetName: ['tinker'], + queueWorkTargetName: ['queue-work'], + cacheClearTargetName: ['cache-clear'], + routeListTargetName: ['route-list'], + }, + false + ); +} \ No newline at end of file diff --git a/packages/php/src/laravel/index.ts b/packages/php/src/laravel/index.ts new file mode 100644 index 000000000..3940c06fa --- /dev/null +++ b/packages/php/src/laravel/index.ts @@ -0,0 +1 @@ +export { createNodesV2 } from './plugin/create-nodes'; \ No newline at end of file diff --git a/packages/php/src/laravel/plugin/create-nodes.spec.ts b/packages/php/src/laravel/plugin/create-nodes.spec.ts new file mode 100644 index 000000000..0a7c43840 --- /dev/null +++ b/packages/php/src/laravel/plugin/create-nodes.spec.ts @@ -0,0 +1,180 @@ +import { CreateNodesContext } from '@nx/devkit'; +import { createLaravelNode } from './create-nodes'; +import { join } from 'path'; + +jest.mock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + existsSync: jest.fn(), + readdirSync: jest.fn(), +})); + +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + readJsonFile: jest.fn(), + getPackageManagerCommand: jest.fn(() => ({ + exec: 'npm', + run: 'npm run', + install: 'npm install', + })), + getNamedInputs: jest.fn(() => ({})), +})); + +import { existsSync } from 'node:fs'; +import { readJsonFile } from '@nx/devkit'; + +describe('Laravel Plugin', () => { + let context: CreateNodesContext; + + beforeEach(() => { + context = { + workspaceRoot: '/root', + nxJsonConfiguration: {}, + configFiles: [], + }; + jest.clearAllMocks(); + }); + + describe('createLaravelNode', () => { + it('should create nodes for Laravel projects', async () => { + const configFile = 'apps/my-app/artisan'; + + (existsSync as jest.Mock).mockImplementation((path: string) => { + // The paths will include workspace root, so check with includes + if (path.includes('apps/my-app/artisan')) return true; + if (path.includes('apps/my-app/bootstrap/app.php')) return true; + if (path.includes('apps/my-app/config/app.php')) return true; + if (path.includes('apps/my-app/routes/web.php')) return true; + if (path.includes('apps/my-app/composer.json')) return true; + return false; + }); + + (readJsonFile as jest.Mock).mockReturnValue({ + require: { + 'laravel/framework': '^10.0', + }, + scripts: { + 'test': 'phpunit', + 'test:unit': 'php artisan test --testsuite=Unit', + 'test:feature': 'php artisan test --testsuite=Feature', + }, + }); + + const result = await createLaravelNode(configFile, {}, context); + + expect(result.projects).toBeDefined(); + expect(result.projects['my-app']).toBeDefined(); + + const project = result.projects['my-app']; + expect(project.root).toBe('apps/my-app'); + expect(project.projectType).toBe('application'); + + // Check standard targets + expect(project.targets.serve).toBeDefined(); + expect(project.targets.migrate).toBeDefined(); + expect(project.targets['migrate-fresh']).toBeDefined(); + expect(project.targets.tinker).toBeDefined(); + expect(project.targets['queue-work']).toBeDefined(); + expect(project.targets['cache-clear']).toBeDefined(); + expect(project.targets['route-list']).toBeDefined(); + + // Check custom artisan commands from composer.json + expect(project.targets['test-unit']).toBeDefined(); + expect(project.targets['test-feature']).toBeDefined(); + + // Verify target properties + expect(project.targets.serve.command).toBe('php artisan serve'); + expect(project.targets.serve.metadata.technologies).toContain('laravel'); + expect(project.targets.migrate.dependsOn).toContain('^install'); + }); + + it('should not create nodes for non-Laravel projects', async () => { + const configFile = 'apps/my-app/artisan'; + + (existsSync as jest.Mock).mockImplementation((path: string) => { + if (path.endsWith('artisan')) return true; + // Missing Laravel-specific files + return false; + }); + + const result = await createLaravelNode(configFile, {}, context); + + expect(result).toEqual({}); + expect(result.projects).toBeUndefined(); + }); + + it('should respect custom target names', async () => { + const configFile = 'apps/my-app/artisan'; + + (existsSync as jest.Mock).mockImplementation((path: string) => { + if (path.includes('apps/my-app/artisan')) return true; + if (path.includes('apps/my-app/bootstrap/app.php')) return true; + if (path.includes('apps/my-app/config/app.php')) return true; + if (path.includes('apps/my-app/routes/web.php')) return true; + if (path.includes('apps/my-app/composer.json')) return true; + return false; + }); + + (readJsonFile as jest.Mock).mockReturnValue({ + require: { + 'laravel/framework': '^10.0', + }, + }); + + const options = { + serveTargetName: 'dev-server', + migrateTargetName: 'db-migrate', + }; + + const result = await createLaravelNode(configFile, options, context); + + const project = result.projects['my-app']; + expect(project.targets['dev-server']).toBeDefined(); + expect(project.targets['db-migrate']).toBeDefined(); + expect(project.targets.serve).toBeUndefined(); + expect(project.targets.migrate).toBeUndefined(); + }); + + it('should handle projects without composer.json', async () => { + const configFile = 'apps/my-app/artisan'; + + (existsSync as jest.Mock).mockImplementation((path: string) => { + if (path.includes('apps/my-app/artisan')) return true; + if (path.includes('apps/my-app/bootstrap/app.php')) return true; + if (path.includes('apps/my-app/config/app.php')) return true; + if (path.includes('apps/my-app/routes/web.php')) return true; + if (path.includes('apps/my-app/composer.json')) return false; // No composer.json + return false; + }); + + const result = await createLaravelNode(configFile, {}, context); + + expect(result.projects).toBeDefined(); + expect(result.projects['my-app']).toBeDefined(); + + const project = result.projects['my-app']; + expect(project.targets.serve).toBeDefined(); + }); + + it('should handle composer.json read errors gracefully', async () => { + const configFile = 'apps/my-app/artisan'; + + (existsSync as jest.Mock).mockImplementation((path: string) => { + if (path.includes('apps/my-app/artisan')) return true; + if (path.includes('apps/my-app/bootstrap/app.php')) return true; + if (path.includes('apps/my-app/config/app.php')) return true; + if (path.includes('apps/my-app/routes/web.php')) return true; + if (path.includes('apps/my-app/composer.json')) return true; + return false; + }); + + (readJsonFile as jest.Mock).mockImplementation(() => { + throw new Error('Invalid JSON'); + }); + + const result = await createLaravelNode(configFile, {}, context); + + expect(result.projects).toBeDefined(); + expect(result.projects['my-app']).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/packages/php/src/laravel/plugin/create-nodes.ts b/packages/php/src/laravel/plugin/create-nodes.ts new file mode 100644 index 000000000..f0ff0f8f7 --- /dev/null +++ b/packages/php/src/laravel/plugin/create-nodes.ts @@ -0,0 +1,317 @@ +import { + CreateNodesContext, + CreateNodesFunction, + CreateNodesV2, + ProjectConfiguration, + TargetConfiguration, + createNodesFromFiles, + getPackageManagerCommand, + readJsonFile, +} from '@nx/devkit'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { toProjectName } from 'nx/src/config/to-project-name'; +import { hashObject } from 'nx/src/hasher/file-hasher'; +import { workspaceDataDirectory } from 'nx/src/utils/cache-directory'; +import { ComposerJson } from '../../utils/model'; + +export interface LaravelPluginOptions { + serveTargetName?: string; + migrateTargetName?: string; + migrateFreshTargetName?: string; + tinkerTargetName?: string; + queueWorkTargetName?: string; + cacheClearTargetName?: string; + routeListTargetName?: string; +} + +export const createLaravelNode: CreateNodesFunction< + LaravelPluginOptions +> = async ( + configFile: string, + options: LaravelPluginOptions, + context: CreateNodesContext +) => { + const projectPath = dirname(configFile); + + if (!isLaravelProject(projectPath, context.workspaceRoot)) { + return {}; + } + + const normalizedOptions = normalizeOptions(options ?? {}); + const pmc = getPackageManagerCommand(); + + const project = await createProject( + projectPath, + normalizedOptions, + context, + pmc + ); + + return { + projects: { + [project.name]: project, + }, + }; +}; + +function isLaravelProject(projectPath: string, workspaceRoot: string): boolean { + const absoluteProjectPath = join(workspaceRoot, projectPath); + + if ( + !existsSync(join(absoluteProjectPath, 'artisan')) || + !existsSync(join(absoluteProjectPath, 'composer.json')) + ) { + return false; + } + + // Check for Laravel-specific directories + const requiredPaths = [ + 'bootstrap/app.php', + 'config/app.php', + 'routes/web.php', + ]; + + for (const path of requiredPaths) { + if (!existsSync(join(absoluteProjectPath, path))) { + return false; + } + } + + // Optional: Check composer.json for laravel/framework + const composerPath = join(absoluteProjectPath, 'composer.json'); + if (existsSync(composerPath)) { + try { + const composer = readJsonFile(composerPath); + const require = composer.require || {}; + const requireDev = composer['require-dev'] || {}; + const deps = { ...require, ...requireDev }; + // If composer.json exists and is readable, check for Laravel + if (!deps['laravel/framework']) { + return false; + } + } catch (e) { + // If we can't read composer.json, continue - it might still be a Laravel project + } + } + + return true; +} + +async function createProject( + projectRoot: string, + options: LaravelPluginOptions, + context: CreateNodesContext, + pmc: ReturnType +): Promise { + const absoluteProjectPath = join(context.workspaceRoot, projectRoot); + const projectName = projectRoot.split('/').pop(); + + const targets: Record = {}; + + targets[options.serveTargetName] = { + command: 'php artisan serve', + options: { + cwd: projectRoot, + }, + metadata: { + technologies: ['php', 'laravel'], + description: 'Start the Laravel development server', + help: { + command: 'php artisan serve --help', + example: { + options: { + port: 8000, + host: '127.0.0.1', + }, + }, + }, + }, + }; + + targets[options.migrateTargetName] = { + command: 'php artisan migrate', + options: { + cwd: projectRoot, + }, + dependsOn: ['^install'], + metadata: { + technologies: ['php', 'laravel'], + description: 'Run the database migrations', + help: { + command: 'php artisan migrate --help', + example: { + options: { + force: true, + seed: true, + }, + }, + }, + }, + }; + + targets[options.migrateFreshTargetName] = { + command: 'php artisan migrate:fresh --seed', + options: { + cwd: projectRoot, + }, + dependsOn: ['^install'], + metadata: { + technologies: ['php', 'laravel'], + description: 'Drop all tables and re-run all migrations with seeding', + help: { + command: 'php artisan migrate:fresh --help', + example: {}, + }, + }, + }; + + targets[options.tinkerTargetName] = { + command: 'php artisan tinker', + options: { + cwd: projectRoot, + }, + metadata: { + technologies: ['php', 'laravel'], + description: 'Interact with your application using Laravel Tinker REPL', + help: { + command: 'php artisan tinker --help', + example: {}, + }, + }, + }; + + targets[options.queueWorkTargetName] = { + command: 'php artisan queue:work', + options: { + cwd: projectRoot, + }, + metadata: { + technologies: ['php', 'laravel'], + description: 'Start processing jobs on the queue', + help: { + command: 'php artisan queue:work --help', + example: { + options: { + queue: 'default', + sleep: 3, + tries: 3, + }, + }, + }, + }, + }; + + targets[options.cacheClearTargetName] = { + command: + 'php artisan cache:clear && php artisan config:clear && php artisan route:clear && php artisan view:clear', + options: { + cwd: projectRoot, + }, + metadata: { + technologies: ['php', 'laravel'], + description: 'Clear all Laravel caches', + help: { + command: 'php artisan cache:clear --help', + example: {}, + }, + }, + }; + + targets[options.routeListTargetName] = { + command: 'php artisan route:list', + options: { + cwd: projectRoot, + }, + metadata: { + technologies: ['php', 'laravel'], + description: 'List all registered routes', + help: { + command: 'php artisan route:list --help', + example: { + options: { + path: 'api', + method: 'GET', + }, + }, + }, + }, + }; + + // Add custom artisan commands from composer.json scripts + const composerPath = join(absoluteProjectPath, 'composer.json'); + if (existsSync(composerPath)) { + try { + const composer = readJsonFile(composerPath); + if (composer.scripts) { + for (const [scriptName, scriptCommand] of Object.entries( + composer.scripts + )) { + if ( + typeof scriptCommand === 'string' && + scriptCommand.includes('artisan') + ) { + const targetName = scriptName.replace(/:/g, '-'); + if (!targets[targetName]) { + targets[targetName] = { + command: scriptCommand, + options: { + cwd: projectRoot, + }, + metadata: { + technologies: ['php', 'laravel'], + description: `Custom artisan command: ${scriptName}`, + }, + }; + } + } + } + } + } catch (e) { + // Ignore errors reading composer.json + } + } + + const composerJson = readJsonFile( + join(context.workspaceRoot, projectRoot, 'composer.json') + ); + + return { + name: composerJson.name ?? toProjectName(projectRoot), + root: projectRoot, + projectType: 'application', + targets, + }; +} + +function normalizeOptions( + options: LaravelPluginOptions +): Required { + return { + serveTargetName: options.serveTargetName ?? 'serve', + migrateTargetName: options.migrateTargetName ?? 'migrate', + migrateFreshTargetName: options.migrateFreshTargetName ?? 'migrate-fresh', + tinkerTargetName: options.tinkerTargetName ?? 'tinker', + queueWorkTargetName: options.queueWorkTargetName ?? 'queue-work', + cacheClearTargetName: options.cacheClearTargetName ?? 'cache-clear', + routeListTargetName: options.routeListTargetName ?? 'route-list', + }; +} + +export const createNodesV2: CreateNodesV2 = [ + '**/artisan', + async (configFiles, options, context) => { + const optionsHash = hashObject(options ?? {}); + const cachePath = join( + workspaceDataDirectory, + `laravel-${optionsHash}.hash` + ); + + return await createNodesFromFiles( + createLaravelNode, + configFiles, + options, + context + ); + }, +];