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

Jstree Laravel Package

vakata/jstree

jsTree is a jQuery plugin for creating interactive, accessible tree views. It supports HTML and JSON data sources, drag-and-drop, searching, checkboxes, lazy loading, themes, and a plugin architecture, with broad browser support and customizable behavior.

View on GitHub
Deep Wiki
Context7

Getting Started

jsTree is a client-side jQuery plugin — there is no service provider, config file, or Composer package to install. In a Laravel app the "integration" is entirely: (a) get the JS/CSS into Vite, (b) write JSON endpoints, (c) wire events back to a controller. Budget your time accordingly; 80% of the work is the JSON contract.

Install:

npm i jquery jstree
npm i -D @types/jstree   # no bundled types

Fix the jQuery global before anything else. jsTree's dist bundle expects window.jQuery. Because ES imports are hoisted, import $ from 'jquery'; window.$ = $; import 'jstree' does not work reliably. Use a shim module so evaluation order is explicit:

// resources/js/jquery.js
import $ from 'jquery';
window.$ = window.jQuery = $;
export default $;

// resources/js/app.js
import $ from './jquery';       // evaluated first → window.jQuery set
import 'jstree';                // now sees the global
import 'jstree/dist/themes/default/style.min.css';

Where to look first: the core.data docs section (node JSON shape), the plugin list, and dist/ in node_modules/jstree (jstree.js, themes/default/style.min.css, sprite 32px.png/throbber.gif). Skim demo/ in the repo for a reference backend, but ignore jstree-php — it is legacy PHP, not Laravel.

First use case — a category tree backed by one query:

// routes/web.php
Route::get('/categories/tree', [CategoryTreeController::class, 'index']);
public function index()
{
    return Category::orderBy('position')->get(['id', 'parent_id', 'name'])
        ->map(fn ($c) => [
            'id'     => 'cat_'.$c->id,                 // string ids, see Gotchas
            'parent' => $c->parent_id ? 'cat_'.$c->parent_id : '#',
            'text'   => $c->name,
            'data'   => ['slug' => $c->slug],          // custom payload
        ]);
}
<div wire:ignore x-data x-init="
  const $t = $(this.$refs.tree);
  $t.jstree({
    core: { data: @js($nodes), multiple: false, check_callback: true },
    plugins: ['wholerow', 'state', 'types'],
    types: { '#': { valid_children: ['default'] } }
  }).on('changed.jstree', (e, d) => {
    if (d.selected.length) Livewire.dispatch('category-picked', { id: d.selected[0] });
  });
" x-ref="tree"></div>

That's a working read-only tree. Everything below is the escalation path.

Implementation Patterns

Pick one shape and stick to it. jsTree accepts nested children or flat parent references — never both; if children exists, parent is ignored for that node. Nested is simpler for small trees; flat is what you want as soon as you add AJAX.

Lazy loading per node. Serve only roots first, then children on expand. The reliable form is a fixed URL plus a data callback so the request shape is explicit rather than relying on jsTree's URL-id appending:

core: {
  data: (node) => ({
    url: '/categories/tree',
    data: (n) => ({ parent: n.id === '#' ? null : n.id.replace('cat_', '') }),
    dataType: 'json',
  }),
  cache: false,
}

Or a dedicated route with model binding: Route::get('/categories/{category}/children', ...). Guard authorization per-branch ($this->authorize('view', $category)) — lazy endpoints are as exposed as any other route.

Drag-and-drop persistence. Enable dnd + types, constrain with valid_children/max_depth, and gate with core.check_callback. Then persist from the move_node event:

.on('move_node.jstree', (e, d) => {
  if (d.instance !== tree) return;                // ignore other trees
  axios.patch(`/categories/${id(d.node.id)}/move`, {
    parent_id: parentId(d.parent),
    position: d.position,
  }).catch(() => d.instance.refresh_node(d.parent));
})

Server-side, wrap in DB::transaction, renumber siblings, and reject cycles (check_callback runs client-side only — a crafted request can still make a node its own ancestor). Walk ancestors before saving.

Checkboxes for bulk actions. plugins: ['checkbox'], checkbox.tie_selection: false, then tree.get_checked() for cascade semantics, or get_bottom_checked() / get_top_checked() when you only want leaves / roots. Sync to a pivot with sync().

Search. Client-side search only sees loaded nodes — fine for a fully-inlined tree, wrong for a lazy one. For lazy trees add a server endpoint and use search.ajax, returning the node plus its ancestor chain so jsTree can open the path:

plugins: ['search'],
search: { show_only_matches: true, ajax: { url: '/categories/search', data: t => ({ q: t }) } }

Reusable Blade/Alpine component. Wrap init in x-init/onMounted, call $t.jstree('destroy') on teardown, and put wire:ignore on the container so Livewire doesn't re-render the DOM out from under jsTree. Expose the instance ($t.jstree(true)) rather than re-selecting.

Server-driven selection & state. state: { key: 'categories-'.$user->id } persists open/selected nodes to localStorage; for cross-device persistence, store the expanded ids in a preferences column and open_node() them after ready.jstree fires. Use open_node(id, cb, true) to avoid animation storms on deep paths.

Types plugin for icon/rules per model. types: { folder: { icon: 'i-folder', max_children: 500 }, file: { icon: 'i-file', max_depth: 1 } } keeps policy in config instead of scattered check_callback branches, and lets you map Eloquent is_folder/mime straight to a type name.

Testing. Feature-test the JSON endpoints (shape, authorization, cycle rejection) and use Dusk for the interaction itself — dragging in jsTree via unit tests is not worth it.

Gotchas and Tips

  • Node ids must be selector-safe strings. jsTree resolves nodes with $('#id') internally, so ids containing ., :, [, or starting with a digit break get_node, select_node, and open_node. Prefix everything: cat_123, user_9. This is the single most common bug report.
  • Numeric ids become strings, and data.original keeps your types. fromJson(["id" => 3]) yields "3" on the client; node.id === 3 is false. Always String() compare. Conversely, anything you attach in JSON (including the data key) is preserved on node.original — use it instead of a second fetch on select.
  • move_node fires for programmatic moves too. If you build nodes server-side and insert them, guard the sync handler with a flag or you'll PATCH in a loop.
  • jsTree's own AJAX bypasses Laravel's CSRF. For POST/PATCH data endpoints add headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content } to core.data, and remember SPA/Inertia apps need X-Requested-With to get JSON 422s instead of redirects.
  • Destroy before re-init. Re-calling .jstree({...}) on an initialized container silently no-ops; leftover .on('select_node.jstree') handlers stack and fire N times. Always $t.jstree('destroy') and namespace or off('.jstree') your handlers.
  • Two access forms, two behaviours. $t.jstree(true) returns the instance (methods return values); $t.jstree('get_selected') is the getter form. Mixing them up returns undefined and produces "it works in the console but not in code" reports.
  • search requires the plugin in plugins and silently does nothing otherwise — and show_only_matches on a deep tree can hide a match whose ancestors aren't loaded. Test with lazy loading enabled.
  • Asset paths. The default theme CSS references 32px.png, 40px.png, and throbber.gif by relative URL. Vite usually rewrites these, but if spinners/icons 404 (common with CDN copies or custom assetsDir), either copy the sprite next to the emitted CSS or set themes: { icons: false } and style with your own icon font.
  • Performance ceiling is real. No virtual scrolling. wholerow plus CSS animation on a 5k-node tree is painful: set core.animation: false, lazy-load, cap children per fetch, and prefer types icons over image sprites. Above a few thousand visible nodes, reconsider the widget.
  • state plugin keys are id-based, so changing id prefixes (or the user's permissions) invalidates saved state and can leave nodes pointing at deleted records — prune on ready.jstree or version the key (categories-v2-{userId}).
  • Vite dev-server quirk: if you see jQuery is not defined only in npm run dev, add jstree to optimizeDeps.include and keep the shim module as the first import in your entry file.
  • Version pin. MIT, but the last release was 2024-09 and upstream is quiet; pin the exact version, keep an eye on the jQuery 4 transition, and don't assume new plugins.
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.
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource