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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,24 @@ render for the passed exception class.

**Note** Need to provide the full exception class name (FQCN) to the method, it automatically imports it.

#### addMiddlewarePrependToGroup

Adds middleware to a named group via `prependToGroup` inside the `withMiddleware` closure.
Accepts a single class name or string, or an array of them. Skips values that already exist in the target group.
Does not affect other groups.
Supports `InsertPositionEnum::Start` and `InsertPositionEnum::End` to control the insertion position. `End` is used by default.

```php
new AppBootstrapBuilder(bootstrap_path('app.php'))
->addMiddlewarePrependToGroup('api', [
MyMiddleware::class,
'throttle:60,1',
])
->addMiddlewarePrependToGroup('web', WebMiddleware::class,, InsertPositionEnum::Start)
->save();

**Note:** Provide the full class name (FQCN) for class-based middleware — the method imports it automatically.

## Contributing

Thank you for considering contributing to Laravel Builder package! The contribution guide
Expand Down
18 changes: 18 additions & 0 deletions src/Builders/AppBootstrapBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

namespace RonasIT\Larabuilder\Builders;

use Illuminate\Support\Arr;
use RonasIT\Larabuilder\Enums\InsertPositionEnum;
use RonasIT\Larabuilder\Visitors\AppBootstrapVisitors\AddExceptionsRender;
use RonasIT\Larabuilder\Visitors\AppBootstrapVisitors\AddMiddlewarePrependToGroup;

class AppBootstrapBuilder extends PHPFileBuilder
{
Expand All @@ -25,4 +28,19 @@ public function addExceptionsRender(string $exceptionClass, string $renderBody,

return $this;
}

public function addMiddlewarePrependToGroup(string $group, string|array $middleware, InsertPositionEnum $position = InsertPositionEnum::End): self
{
$middlewares = Arr::wrap($middleware);

$this->traverser->addVisitor(new AddMiddlewarePrependToGroup($group, $middlewares, $position));

$imports = array_filter($middlewares, fn ($middleware) => class_exists($middleware));

if (!empty($imports)) {
$this->addImports($imports);
}

return $this;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

abstract class AbstractAppBootstrapVisitor extends NodeVisitorAbstract
{
protected const FORBIDDEN_NODES = [
protected const array FORBIDDEN_NODES = [
Class_::class,
Trait_::class,
Interface_::class,
Expand Down
148 changes: 148 additions & 0 deletions src/Visitors/AppBootstrapVisitors/AddMiddlewarePrependToGroup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?php

namespace RonasIT\Larabuilder\Visitors\AppBootstrapVisitors;

use PhpParser\Node\Arg;
use PhpParser\Node\ArrayItem;
use PhpParser\Node\Expr\Array_;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Expr\Closure;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt\Expression;
use PhpParser\Node\Stmt\Nop;
use RonasIT\Larabuilder\Enums\InsertPositionEnum;

class AddMiddlewarePrependToGroup extends AbstractAppBootstrapVisitor
{
public function __construct(
protected string $group,
protected array $middlewares,
protected InsertPositionEnum $position,
) {
parent::__construct(
parentMethod: 'withMiddleware',
targetMethod: 'prependToGroup',
);
}

protected function insertNode(MethodCall $node): MethodCall
{
/** @var Closure $closure */
$closure = $node->args[0]->value;

$this->removeNopPlaceholder($closure);

$statementIndex = $this->findMiddlewareGroupIndex($closure->stmts);

if (is_null($statementIndex)) {
$closure->stmts[] = $this->buildPrependToGroupCall();
} else {
$this->updateMiddlewareGroup($closure, $statementIndex);
}

return $node;
}

protected function removeNopPlaceholder(Closure $closure): void
{
if (!empty($closure->stmts) && ($closure->stmts[0] ?? null) instanceof Nop) {
array_shift($closure->stmts);
}
}

protected function findMiddlewareGroupIndex(array $stmts): ?int
{
return array_find_key($stmts, function (Expression $stmt) {
Comment thread
artengin marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check every existing group call before appending

When a bootstrap already has more than one prependToGroup('api', ...) call, this returns only the first match, so mergeMiddlewares() checks duplicates against that first argument list only. If the middleware being added is already present in a later call for the same group, the visitor appends it to the first call as well and the generated bootstrap runs the same middleware twice; scan all matching calls, or consolidate them, before deciding the middleware is new.

Useful? React with 👍 / 👎.

return !empty($stmt->expr->name)
&& $stmt->expr->name->toString() === $this->targetMethod
&& $stmt->expr->args[0]->value->value === $this->group;
Comment thread
AZabolotnikov marked this conversation as resolved.
Comment thread
AZabolotnikov marked this conversation as resolved.
});
}
Comment thread
AZabolotnikov marked this conversation as resolved.

protected function updateMiddlewareGroup(Closure $closure, int $groupIndex): void
{
$originalMiddlewares = $closure->stmts[$groupIndex]->expr->args[1]->value->value
?? $closure->stmts[$groupIndex]->expr->args[1]->value->class->name
?? $closure->stmts[$groupIndex]->expr->args[1]->value->items;
Comment thread
AZabolotnikov marked this conversation as resolved.

$originalMiddlewares = is_array($originalMiddlewares)
? $originalMiddlewares
: [new ArrayItem($closure->stmts[$groupIndex]->expr->args[1]->value)];

$mergedMiddlewares = $this->mergeMiddlewares($originalMiddlewares);

$closure->stmts[$groupIndex]->expr->args[1] = $this->buildMiddlewareArg($mergedMiddlewares);
Comment thread
AZabolotnikov marked this conversation as resolved.
}

protected function mergeMiddlewares(array $originalMiddlewareList): array
{
$filteredNewList = [];

foreach ($this->middlewares as $middleware) {
$sameMiddlewareKey = array_find_key(
$originalMiddlewareList,
fn ($originalMiddleware) => $this->isSameMiddleware($middleware, $originalMiddleware),
);

if (is_null($sameMiddlewareKey)) {
$filteredNewList[] = $this->makeArrayItem($middleware);
}
}

return match ($this->position) {
InsertPositionEnum::Start => [...$filteredNewList, ...$originalMiddlewareList],
InsertPositionEnum::End => [...$originalMiddlewareList, ...$filteredNewList],
};
}

protected function isSameMiddleware(string $newMiddleware, ArrayItem $originalMiddleware): bool
{
if ($originalMiddleware->value instanceof ClassConstFetch) {
$originalName = $originalMiddleware->value->class->name;

return $originalName === $newMiddleware
|| $originalName === class_basename($newMiddleware);
Comment on lines +107 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Resolve class constants before de-duplicating

When the target group already contains an imported class with the same short name as the middleware being added, this basename comparison treats different classes as duplicates. For example, if the bootstrap imports App\Http\Middleware\Authenticate and the group contains Authenticate::class, adding Illuminate\Auth\Middleware\Authenticate::class is skipped even though it resolves to a different middleware, so the requested middleware is omitted from the saved file; compare against resolved FQCNs instead of only class_basename().

Useful? React with 👍 / 👎.

}

return $originalMiddleware->value->value === $newMiddleware;
}

protected function buildPrependToGroupCall(): Expression
{
$middlewareList = $this->getMiddlewareList();

$methodCall = new MethodCall(new Variable('middleware'), new Identifier($this->targetMethod), [

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use the closure's middleware variable name

If the bootstrap file names the withMiddleware callback parameter anything other than $middleware, for example function (Middleware $m): void, this inserts $middleware->prependToGroup(...) and leaves the generated bootstrap code referencing an undefined variable. The visitor already has the closure in insertNode, so the new method call should be built with the actual first parameter name instead of a hard-coded one.

Useful? React with 👍 / 👎.

new Arg(new String_($this->group)),
$this->buildMiddlewareArg($middlewareList),
]);

return new Expression($methodCall);
}

protected function buildMiddlewareArg(array $middlewares): Arg
{
return new Arg(new Array_($middlewares));
}

protected function getMiddlewareList(): array
{
return array_map(fn ($middleware) => $this->makeArrayItem($middleware), $this->middlewares);
}

protected function makeArrayItem(string $middleware): ArrayItem
{
if (class_exists($middleware)) {
$basename = class_basename($middleware);

$value = new ClassConstFetch(new Name($basename), 'class');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid unaliased middleware basenames

When the bootstrap file already imports a different class with the same short name as the middleware being added, this drops the namespace and later adds another unaliased use, so the generated file either has a duplicate import name or FakeClass::class resolves to the wrong class. This can happen with common middleware basenames such as Authenticate; use a fully-qualified Name here or add aliasing before shortening the class name.

Useful? React with 👍 / 👎.

} else {
$value = new String_($middleware);
}

return new ArrayItem($value);
}
}
77 changes: 77 additions & 0 deletions tests/AppBootstrapBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

namespace RonasIT\Larabuilder\Tests;

use Illuminate\Auth\Middleware\Authenticate;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\ExpectationFailedException;
use RonasIT\Larabuilder\Builders\AppBootstrapBuilder;
use RonasIT\Larabuilder\Enums\InsertPositionEnum;
use RonasIT\Larabuilder\Exceptions\InvalidBootstrapAppFileException;
use RonasIT\Larabuilder\Exceptions\InvalidPHPCodeException;
use RonasIT\Larabuilder\Tests\Support\Classes\FakeClass;
use RonasIT\Larabuilder\Tests\Support\Traits\PHPFileBuilderTestMockTrait;
Comment thread
AZabolotnikov marked this conversation as resolved.
use Symfony\Component\HttpKernel\Exception\HttpException;

Expand Down Expand Up @@ -137,4 +140,78 @@ public function testInvalidBootstrapAppFileException(string $fixture, string $ty
)
->save();
}

public function testAddMiddlewarePrependToGroup()
{
$file = $this->generateOriginalStructurePath('bootstrap_empty.php');

$this->mockNativeFunction(
'RonasIT\Larabuilder\Builders',
$this->callFilePutContent($file, 'bootstrap_with_prepend_group.php'),
);

new AppBootstrapBuilder($file)
->addMiddlewarePrependToGroup(
group: 'api',
middleware: FakeClass::class,
)
->addMiddlewarePrependToGroup(
group: 'api',
middleware: 'throttle:60,10',
position: InsertPositionEnum::Start,
)
->addMiddlewarePrependToGroup(
group: 'web',
middleware: [
'throttle:10,10',
FakeClass::class,
],
)
->save();
Comment thread
AZabolotnikov marked this conversation as resolved.
}

public function testAddMiddlewarePrependToGroupExistsMiddlewares()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This test isn't quite correct: it doesn't actually change the contents of prependToGroup, but it still adds an unnecessary import.
If the intent was to show that duplicate code isn't created inside prependToGroup, the fixture should have been named bootstrap_without_changed_prepend_group, and the import shouldn't appear either.

I suggest we either:

  1. In a follow-up PR, figure out how to avoid importing a class when no changes were made to PrependToGroup (we'll need this capability in the future anyway), or
  2. Simply rework the fixture and test to properly demonstrate that duplicates aren't created and the fixture remains unchanged.

{
$file = $this->generateOriginalStructurePath('bootstrap_with_prepend_group.php');

$this->mockNativeFunction(
'RonasIT\Larabuilder\Builders',
$this->callFilePutContent($file, 'bootstrap_without_changed_prepend_group.php'),
);

new AppBootstrapBuilder($file)
->addMiddlewarePrependToGroup('api', [
'throttle:60,10',
Authenticate::class,
])
->save();
}

public static function provideMiddlewareAsString(): array
{
return [
[
'original' => 'bootstrap_with_prepend_group_as_string.php',
'result' => 'bootstrap_with_prepend_group_as_string.php',
],
[
'original' => 'bootstrap_with_prepend_group_as_string_set_class.php',
'result' => 'bootstrap_with_prepend_group_as_string_set_class.php',
],
];
}

#[DataProvider('provideMiddlewareAsString')]
public function testAddMiddlewarePrependToGroupMiddlewareAsString(string $original, string $result): void
Comment thread
AZabolotnikov marked this conversation as resolved.
{
$file = $this->generateOriginalStructurePath($original);

$this->mockNativeFunction('RonasIT\Larabuilder\Builders', $this->callFilePutContent($file, $result));

new AppBootstrapBuilder($file)
->addMiddlewarePrependToGroup('api', [
'some_middleware',
])
->save();
}
Comment thread
AZabolotnikov marked this conversation as resolved.
}
7 changes: 7 additions & 0 deletions tests/Support/Classes/FakeClass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace RonasIT\Larabuilder\Tests\Support\Classes;

class FakeClass
{
}
19 changes: 19 additions & 0 deletions tests/Support/OriginStructures/bootstrap_with_prepend_group.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Auth\Middleware\Authenticate;

return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->prependToGroup('api', ['throttle:60,10', Authenticate::class]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->prependToGroup('api', 'throttle:60,10');
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->prependToGroup('api', \Illuminate\Auth\Middleware\Authenticate::class);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use RonasIT\Larabuilder\Tests\Support\Classes\FakeClass;

return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->prependToGroup('api', ['throttle:60,10', FakeClass::class]);
$middleware->prependToGroup('web', ['throttle:10,10', FakeClass::class]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();
Loading