yavin/symfony-form-tree
A Symfony bundle providing a Tree form type for editing hierarchical data, such as nested collections or Doctrine tree entities, with recursive child rendering, dynamic add/remove of nodes, and standard form validation support.
The bundle's whole job is small: take a tree-shaped set of entities and turn it into a ChoiceType-compatible choice list whose labels are prefixed with indentation based on depth (Category, — Child, —— Grandchild). Everything else is stock Symfony form behaviour.
Install and register
composer require yavin/symfony-form-tree
There is no Flex recipe. If the bundle class is auto-discovered, you're done; otherwise add it manually:
// config/bundles.php
return [
// ...
Yavin\SymfonyFormTreeBundle\SymfonyFormTreeBundle::class => ['all' => true],
];
Look here first
vendor/yavin/symfony-form-tree/src/ — the form type (Form/Type/TreeType.php) and whatever class performs the flattening/indentation. Read configureOptions() before anything else; it is the actual API documentation for this package.README example — short, but shows the two supported modes: hand it a tree of root entities, or hand it a flat, already-ordered list.ChoiceType/EntityType, most options you already know (choices, choice_label, placeholder, multiple, choice_attr) keep working.First use case: self-referencing category picker
#[ORM\Entity]
class Category
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column]
private string $name;
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
private ?Category $parent = null;
#[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
#[ORM\OrderBy(['name' => 'ASC'])]
private Collection $children;
#[ORM\Column]
private int $level = 0;
public function __toString(): string { return $this->name; }
// getters/setters…
}
final class ProductType extends AbstractType
{
public function __construct(private CategoryRepository $categories) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class)
->add('category', TreeType::class, [
'choices' => $this->categories->findRootsWithChildren(), // or entity mode: 'class' => Category::class
'choice_label' => 'name',
'placeholder' => 'Pick a category',
]);
}
}
Render with the ordinary form_row(); the indentation is baked into the choice labels, so no custom theme is required to get started.
Pick one feeding strategy and stick to it. The type can only indent correctly if the input order is a depth-first walk of the forest. Two reliable ways to get that:
parent IS NULL entities with their children joined (->addSelect('c')), and let the type recurse. Simplest, but the recursion is only as good as your hydration.root, lft (Gedmo NestedSet), path (Baum), or a recursive CTE for plain adjacency lists, then pass the whole array. Cheapest and easiest to cache.Wrap choice building in a small service. Don't build tree arrays in controllers or in five different form types:
final class CategoryTreeProvider
{
public function __construct(private CategoryRepository $repo) {}
/** @return Category[] depth-first */
public function choices(?int $tenantId = null): array
{
return $this->repo->createFlatTreeQueryBuilder($tenantId)
->getQuery()
->getResult();
}
}
Inject it into every form that needs the field (product edit, filter forms, admin screens). Cache the result per request; for slow-changing taxonomies, cache the flattened [id => label] array in Redis and pass it as choices.
Exclude the current node and its subtree when choosing a parent. This is the number-one real-world requirement and the package can't do it for you:
$choices = array_filter(
$this->tree->choices(),
fn (Category $c) => !$c->isDescendantOf($product->getCategory()),
);
Filter before passing, not via choice_attr — you don't want the option rendered at all.
Filters and admin screens. Sonata Admin and EasyAdmin both take a plain choices array happily; build the array once and reuse it for the list filter, the edit form, and the bulk-action form so the indentation is consistent everywhere. Use 'placeholder' => '' (empty string) on filter fields, otherwise Symfony adds an extra "Choose…" entry above your tree.
Multiple selection works as-is with 'multiple' => true, but flat-depth labels become unreadable for more than a handful of levels; pair it with Select2/Choices.js so users can search by label text.
$category->getChildren() is a lazy PersistentCollection, recursion triggers one query per node. Always fetch the subtree in one go (addSelect + leftJoin) or pass the flat ordered list.findAll() gives you insertion order, and indentation will be nonsense. Sort by your tree column (or by parent+name) before the type ever sees the data. entities in the prefix you'll see literal unless the type marks labels safe; prefer a real non-breaking space ("\u{00A0}") or an em dash in your own overrides. Check rendered output on your first integration.children collection that also appears elsewhere) yields duplicated options with the same value; the browser silently keeps the last one and your form posts something you didn't expect.dump($builder->get('category')->getOption('choices'));
bin/console debug:form 'App\Form\ProductType' // shows the TreeType options
configureOptions/choice-label logic, decorate or replace the tree-building service, or provide a custom form_theme block if you need <optgroup> instead of indentation. Since the repo has ~14 stars and zero dependents, pin an exact version in composer.json and diff on upgrade; there is no deprecation safety net.choice_label must be scalar or Stringable. If __toString() on your entity walks up to the parent to build a path, you'll reintroduce N+1 or infinite recursion — return the plain name and let the package handle indentation.How can I help you explore Laravel packages today?