Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion packages/php/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion packages/php/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
8 changes: 4 additions & 4 deletions packages/php/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "."
}
Expand Down
2 changes: 2 additions & 0 deletions packages/php/src/generators/init/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);

Expand Down
25 changes: 25 additions & 0 deletions packages/php/src/generators/init/lib/add-laravel-plugin.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await addPlugin(
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and PHPUnit should really be checking if their config files exist, or else skip.

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
);
}
1 change: 1 addition & 0 deletions packages/php/src/laravel/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createNodesV2 } from './plugin/create-nodes';
180 changes: 180 additions & 0 deletions packages/php/src/laravel/plugin/create-nodes.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading
Loading