Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Symfony Form Tree Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. 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.
  2. The README example — short, but shows the two supported modes: hand it a tree of root entities, or hand it a flat, already-ordered list.
  3. Because the type extends Symfony's 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.

Implementation Patterns

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:

  • Roots + recursion: fetch only 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.
  • Flat ordered list: one query ordered by 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.

Gotchas and Tips

  • N+1 is the default failure mode. If $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.
  • Ordering is not optional. Passing 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.
  • Escaping of indentation markers. Choice labels are HTML-escaped by Symfony. If the package uses   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.
  • Duplicates. Passing roots and the flat list (or a 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.
  • Debug the resolved choices, not the template:
    dump($builder->get('category')->getOption('choices'));
    bin/console debug:form 'App\Form\ProductType'   // shows the TreeType options
    
  • Extension points (small codebase, MIT — read and fork freely): override the form type and its 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.
  • Deep trees break UX before they break the code. 6+ levels produce option labels wider than the select. Cap the depth you feed the picker (e.g. stop at the current branch) and push deep selection into a separate "move" screen with breadcrumbs.
  • 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.
  • Empty tree renders an empty select with the placeholder; no exception, which makes a broken repository method look like a styling bug. Assert non-empty in tests.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect