Skip to content
Open
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ Add new `use TraitName;` statements to a class, trait, or enum. This method auto

**Note:** Need to provide the full trait class name (FQCN); the method will import it automatically.

#### removeClassAttribute

Remove a PHP attribute from the specified class. If the class does not exist in the file a `NodeNotExistException` is thrown.

## Special Laravel structure builders

### Bootstrap app
Expand Down
8 changes: 8 additions & 0 deletions src/Builders/PHPFileBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use RonasIT\Larabuilder\Visitors\PropertyVisitors\AddArrayPropertyItem;
use RonasIT\Larabuilder\Visitors\PropertyVisitors\RemoveArrayPropertyItem;
use RonasIT\Larabuilder\Visitors\PropertyVisitors\SetProperty;
use RonasIT\Larabuilder\Visitors\RemoveClassAttribute;

class PHPFileBuilder
{
Expand Down Expand Up @@ -85,6 +86,13 @@ public function insertCodeToMethod(string $methodName, string $code, InsertPosit
return $this;
}

public function removeClassAttribute(string $className, string $attributeName): self
{
$this->traverser->addVisitor(new RemoveClassAttribute($className, $attributeName));

return $this;
}

public function save(): void
{
$this->traverser->addVisitor(new CloningVisitor());
Expand Down
55 changes: 55 additions & 0 deletions src/Visitors/RemoveClassAttribute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace RonasIT\Larabuilder\Visitors;

use PhpParser\Node;
use PhpParser\Node\Attribute;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\Nop;
use RonasIT\Larabuilder\Exceptions\NodeNotExistException;

class RemoveClassAttribute extends AbstractNodeVisitor
{
protected array $allowedParentNodesTypes = [
Class_::class,
];

public function __construct(
protected string $className,
protected string $attributeName,
) {
}

protected function modify(Node $node): Node
{
/** @var Class_ $node */
$this->validateClassName($node);

$this->removeMatchingAttributes($node);

return $node;
}

protected function validateClassName(Class_ $node): void
{
if ($this->className !== $node->name->name) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Ignore anonymous classes when validating class name

Fresh evidence: even in a PSR-1 single-class file, a nested new class {} creates a Class_ node with no name, and this comparison dereferences $node->name unconditionally. When removeClassAttribute() traverses such a file, it fails before reaching the intended class (or throws NodeNotExistException on the wrong node), so attribute removal is impossible for valid PHP files that include anonymous classes.

Useful? React with 👍 / 👎.

throw new NodeNotExistException('Class', $this->className);
Comment on lines +35 to +36
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Defer class-not-found error until traversal finishes

validateClassName() throws on the first Class_ node whose name differs, so removeClassAttribute() fails in files that contain any additional class node (for example, a second top-level class or an anonymous class encountered before/after the target) even when the requested $className is present. In the anonymous-class case, $node->name is null, so $node->name->name can also crash at runtime. The visitor should track whether a matching named class was found and only throw NodeNotExistException after traversal if none matched.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Multiple classes in a single file violates PSR-1

}
}
Comment on lines +33 to +38
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

validateClassName() throws NodeNotExistException as soon as it encounters a Class_ node whose name doesn’t match the target. If the file contains multiple classes, this will abort on the first non-target class even if the requested class exists later in the AST. Consider instead tracking a hasTargetClass flag: on non-matching classes, just return without changes; on match, set the flag and perform removal; then in afterTraverse() throw NodeNotExistException('Class', $className) only if no matching class was ever found (while still using parentNodeNotFoundHook() for the “no class nodes at all” case).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Multiple classes in a single file violates PSR-1


protected function removeMatchingAttributes(Class_ $node): void
{
foreach ($node->attrGroups as $key => $attrGroup) {
if ($attrGroup instanceof Nop) {
continue;
}

$attrGroup->attrs = array_filter($attrGroup->attrs, fn (Attribute $attr) => $attr->name->name !== $this->attributeName);

// Replace with Nop to avoid leftover `#[]` and preserve original blank lines (full removal shifts token offsets in Printer)
if (empty($attrGroup->attrs)) {
$node->attrGroups[$key] = new Nop();
}
}
}
}
78 changes: 78 additions & 0 deletions tests/PHPFileBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -530,4 +530,82 @@ public function testInsertDuplicateCodeToMethod(string $code): void
->insertCodeToMethod('someMethod', $code)
->save();
}

public function testRemoveClassAttributeNotClass(): void
{
$file = $this->generateOriginalStructurePath('interface.php');

$this->assertExceptionThrew(InvalidStructureTypeException::class, "'RemoveClassAttribute' operation may only be applied to: Class.");

new PHPFileBuilder($file)
->removeClassAttribute('SomeClass', 'someAttribute')
->save();
}

public function testRemoveClassAttributeWhenClassNotExist(): void
{
$file = $this->generateOriginalStructurePath('class_with_properties.php');

$this->assertExceptionThrew(NodeNotExistException::class, "Class 'AnotherClass' does not exist.");

new PHPFileBuilder($file)
->removeClassAttribute('AnotherClass', 'someAttribute')
->save();
}

public function testRemoveClassAttribute(): void
{
$file = $this->generateOriginalStructurePath('class_with_attributes.php');

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

new PHPFileBuilder($file)
->removeClassAttribute('SomeClass', 'MyAttribute')
->removeClassAttribute('SomeClass', 'AnotherAttribute')
->save();
}

public function testRemoveClassAttributeFromGroupedAttributes(): void
{
$file = $this->generateOriginalStructurePath('class_with_grouped_attributes.php');

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

new PHPFileBuilder($file)
->removeClassAttribute('SomeClass', 'MyAttribute')
->save();
}

public function testRemoveClassAttributeMultipleSameNameAttributes(): void
{
$file = $this->generateOriginalStructurePath('class_with_multiple_same_attributes.php');
$this->mockNativeFunction(
'RonasIT\Larabuilder\Builders',
$this->callFilePutContent($file, 'class_without_attributes.php'),
);

new PHPFileBuilder($file)
->removeClassAttribute('SomeClass', 'MyAttribute')
->save();
}

public function testRemoveClassAttributeNoMatchingAttribute(): void
{
$file = $this->generateOriginalStructurePath('class_with_attributes.php');

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

new PHPFileBuilder($file)
->removeClassAttribute('SomeClass', 'NonExistentAttribute')
->save();
}
}
19 changes: 19 additions & 0 deletions tests/Support/OriginStructures/class_with_attributes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace RonasIT\Larabuilder\Tests\Support;

use App\MyAttribute;
use App\SetUp;

#[MyAttribute]
#[AnotherAttribute]
class SomeClass
{
#[MyAttribute]
public int $prop;

#[SetUp]
public function someMethod()
{
}
}
16 changes: 16 additions & 0 deletions tests/Support/OriginStructures/class_with_grouped_attributes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace RonasIT\Larabuilder\Tests\Support;

use App\MyAttribute;
use App\AnotherAttribute;
use App\SetUp;

#[MyAttribute, AnotherAttribute]
class SomeClass
{
#[SetUp]
public function someMethod()
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace RonasIT\Larabuilder\Tests\Support;

use App\MyAttribute;
use App\SetUp;

#[MyAttribute(1234)]
#[MyAttribute(5678)]
class SomeClass
{
#[MyAttribute]
public int $prop;

#[SetUp]
public function someMethod()
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace RonasIT\Larabuilder\Tests\Support;

use App\MyAttribute;
use App\SetUp;

#[MyAttribute]
#[AnotherAttribute]
class SomeClass
{
#[MyAttribute]
public int $prop;

#[SetUp]
public function someMethod()
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace RonasIT\Larabuilder\Tests\Support;

use App\MyAttribute;
use App\AnotherAttribute;
use App\SetUp;

#[AnotherAttribute]
class SomeClass
{
#[SetUp]
public function someMethod()
{
}
}
17 changes: 17 additions & 0 deletions tests/fixtures/PHPFileBuilderTest/class_without_attributes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace RonasIT\Larabuilder\Tests\Support;

use App\MyAttribute;
use App\SetUp;

class SomeClass
{
#[MyAttribute]
public int $prop;

#[SetUp]
public function someMethod()
{
}
}