A minimal FiveM framework. The core handles player sessions and nothing else. Everything else is an optional addon you opt into.
Every existing FiveM framework ships a fixed stack: inventory, jobs, money, vehicles, society, characters. And the pieces assume each other exists, so anything that diverges from the assumed shape means fighting the framework.
Bonsai inverts that. On connect, insert the player's license into a row. On subsequent joins, load it. That is the entire core. Identity, characters, money, items, permissions, jobs are all addons that live under bonsai/addons/<name>/ and own their schema, dependencies, and lifecycle.
The whole framework runs as one FiveM resource. You start bonsai and the addons listed in config.lua boot in dependency order. No coordinating five resources in server.cfg, no version drift between framework parts, no cross-resource event soup.
Every addon declares its Dependencies and OptionalDependencies with real semver ranges in its bonsai.lua manifest. The resolver enforces them on boot:
- Hard dep missing or version mismatch → the addon is dropped with a structured error and the message tells you exactly what's wrong (
required dependency "bonsai" version 0.0.5 found, need >=0.1.0). - Optional dep missing → silent. It's optional.
- Optional dep present but version mismatch → a
WARNline names the addon, the version it has, the range it expected, and reminds you that features depending on it may not be available. The addon still boots. - Optional dep present and version satisfies →
addon:isLoaded("name")returnstrueand the feature lights up.
The screenshot above is a real boot: currency declares permissions = "^0.1.0" as an optional dep, the installed permissions is at 0.0.1 (mismatch), so currency boots fine but skips its admin commands and the log flags it. Three addons end up healthy, one warning, zero crashes.
- Minimal core. The player row is
(license, first_seen, last_seen). Nothing else. - First-class addons. Each addon has a manifest, semver dependencies, Lua migrations, and lifecycle hooks. The resolver runs a topological sort and refuses to start on cycles or missing dependencies.
- One resource. Addons are subdirectories of
bonsai/addons/<name>/, loaded by the core's own orchestrator. Noensureordering. - 100% OOP.
ox_libis a strict dependency.lib.classfor every class, ox_lib'srequirefor every module. - Strict style. See STYLE_GUIDE.md. Fully typed with LuaLS annotations.
- Clone
bonsaiinto yourresources/folder. - Make sure
ox_libandoxmysqlstart beforebonsai. - Add
ensure bonsaito yourserver.cfg. - Boot the server. The core creates
bonsai_playersandbonsai_migrationson first start via its own migration. - Drop an addon folder into
bonsai/addons/<name>/and list its folder name inbonsai/config.luato enable it.
bonsai/
├── fxmanifest.lua
├── bonsai.lua core's own addon manifest
├── config.lua list of enabled addons
├── shared/ namespace, classes, semver
├── server/ orchestrator, lifecycle, save scheduler
├── client/ client boot + Player class
└── addons/
└── <addonName>/
├── bonsai.lua addon manifest
├── server/main.lua
└── client/main.lua
The framework eats its own dogfood. bonsai/bonsai.lua is the core's own manifest, structurally identical to any addon's manifest. The same scanner that loads addons/<name>/bonsai.lua also loads the core's manifest. The core ends up in bonsai.addons registered under the name bonsai, with its own migration (the one that creates bonsai_players), its own emitter (where lifecycle events fire), and a slot in the dependency graph.
Practical consequences:
- Addons can declare
core = ">=0.1.0"as a dependency and the semver check works against the core's manifest version. - Lifecycle events (
server:playerLoaded, etc.) are subscribed to viabonsai.addons:get("bonsai"):on(...), the same shape addons use for their own events. - Adding a new core-owned table is a migration on the core addon, not a special case.
MySQL.readyfires.migrationRunner.bootstrap()ensuresbonsai_migrationsexists.- The manifest scanner reads
config.luaand loads each addon'sbonsai.luafromaddons/<name>/bonsai.lua, plus the core's own manifest frombonsai/bonsai.lua(the core is treated as an addon calledbonsai). - The core registers its own migration (the one that creates
bonsai_players). - The dependency resolver runs Kahn's algorithm. Cycles, missing deps, and semver mismatches drop the offending addons plus their transitive dependents, with structured errors logged.
- The migration runner applies pending migrations per addon, one transaction each. If a dependency fails, its dependents are pre-failed and skipped.
- Each addon's entry scripts (
sharedEntry,serverEntry) load in dependency order. The current-booting pointer is set around each load soBonsaiPlayer:registerMixinattributes correctly. - Every addon's
addon:boot(fn)callbacks run. bonsai.isReady = true. Lifecycle handlers register, the autosave loop starts, the client bridge registers.- Already-connected players (after a restart) are reconciled.
onClientResourceStart("bonsai")fires.- The server pushes a one-shot
bonsai:client:bootstrapnet event afterserver:playerLoadedruns. The payload carries the addon manifest list, the local player's license, and every addon'sonClientSyncsnapshot. - Each addon's
clientEntryloads, with the current-booting pointer set per addon. - Boot callbacks run.
- The
LocalBonsaiPlayeris constructed and exposed asbonsai.localPlayer. bonsai.isReady = true,client:playerLoadedfires on the core addon's emitter.
Three classes, one shared base:
| Class | Side | Notes |
|---|---|---|
BaseBonsaiPlayer |
shared | Identity (source, license), private._MixinData, the registerMixin mechanism. |
BonsaiPlayer |
server | Extends Base. Adds identifiers, getIdentifier, emitClient, kick, save, markDirty. Stored in bonsai.players (per-source + per-license registry). |
LocalBonsaiPlayer |
client | Extends Base. Represents the local player. Exposed as bonsai.localPlayer (there's only one). |
The asymmetry is intentional: the server has many players, the client has exactly one. The class hierarchy reflects that.
Addons extend the player surface with mixins. Server side:
local addon = bonsai.addons:require("currency")
bonsai.BonsaiPlayer:registerMixin({
---@param self BonsaiPlayer
---@param name string
---@return number
getBalance = function(self, name)
return readBalance(self, name)
end,
})Client side uses bonsai.LocalBonsaiPlayer:
---@class LocalBonsaiPlayer
---@field getBalance fun(self: LocalBonsaiPlayer, name: string): number
bonsai.LocalBonsaiPlayer:registerMixin({
---@param self LocalBonsaiPlayer
---@param name string
---@return number
getBalance = function(self, name)
local state = self.private._MixinData.currency
if (not state) then
return 0
end
return state.Balances[name] or 0
end,
})Names follow the same camelCase verb-noun rule as any other method. Collisions across addons are a hard error at boot.
Mixin methods are added at runtime, so LuaLS can't see them by default. Every addon that registers mixins extends the relevant class inline with a ---@class block (and ---@field rows for each new method) immediately above the registerMixin call. The annotations live next to the implementation that satisfies them, so renaming a method or changing its signature surfaces a diagnostic on the spot. See bonsai/addons/currency/server/main.lua and bonsai/addons/currency/client/main.lua for the pattern.
The core is itself registered as an addon called bonsai, so lifecycle events live on its emitter. Subscribe by name:
bonsai.addons:get("bonsai"):on("server:playerLoaded", function(player)
bonsai.logger.info("player loaded", { license = player:getLicense() })
end)Event names on an addon's emitter are <context>:<camelCaseName>. The addon name is implicit because the emitter is scoped. (FiveM net events like RegisterNetEvent use the fully-qualified <addonName>:<context>:<name> form because they actually cross resource boundaries.)
| Event | Payload |
|---|---|
server:playerConnecting |
(source, name, deferrals) |
server:playerLoading |
(source, license) |
server:playerLoaded |
(player) |
server:playerSaving |
(player) |
server:playerDropped |
(player, reason) |
client:playerLoaded |
(player) |
Addons register save handlers per namespace. The currency addon does this:
addon:onSave("currency", function(player, tx)
local State = getState(player)
for name in pairs(State.Dirty) do
tx:prepare(
"REPLACE INTO currency_balances (license, currency_id, balance) VALUES (?, ?, ?)",
{ player:getLicense(), name, State.Balances[name] or 0 })
end
State.Dirty = {}
end)Setters in the addon call player:markDirty("currency") whenever a value changes. The save scheduler batches every dirty namespace for one player into a single MySQL.transaction.await on the autosave tick (60 s by default) and on disconnect. One round-trip per player per tick, regardless of how many addons wrote.
After server:playerLoaded runs, the core fires a single bonsai:client:bootstrap net event at the player. Addons that need to push per-player state to the client at join time register a sync provider:
addon:onClientSync(function(player)
return snapshotForClient(player) -- any serialisable value
end)The bridge iterates every addon's provider, bundles the results into payload.AddonData, and the client init dispatches each entry as client:initialSync on the corresponding addon's emitter:
addon:on("client:initialSync", function(snapshot, localPlayer)
-- hydrate local state from snapshot
end)One round-trip on connect, regardless of how many addons need initial state. Addons that don't need a sync just don't register a provider.
Group-based player permissions. Define the allowed groups in bonsai/addons/permissions/config/groups.lua:
return {
defaultGroup = "user",
Groups = { "admin", "moderator" },
}defaultGroup is implicit on every player — never stored in the DB, can't be added or removed, and hasPermission("user") always returns true. That makes it the canonical way to mark a permission gate as open-to-everyone.
On boot, any (license, group_name) rows whose group_name is no longer in Groups get wiped — so disabling a group in the config cleanly removes every assignment of it.
API on the player (server and client):
player:hasPermission("admin") -- single group
player:hasPermission({ "admin", "moderator" }) -- any-of
player:getGroups() -- ["admin", "user"]
player:addGroup("admin") -- server only; errors if name not in config
player:removeGroup("admin") -- server onlyServer emits server:groupsChanged on its own emitter with (player, snapshot). Clients receive permissions:client:groupsChanged automatically and the addon's client:groupsChanged emitter event fires after the local cache updates.
Schema: single sidecar table permissions_groups(license, group_name) with composite PK and FK to bonsai_players.
Configurable currencies on the player. Define any number in bonsai/addons/currency/config/currencies.lua:
return {
Currencies = {
{ name = "money", label = "Money", symbol = "$", hasDecimal = true, defaultAmount = 0 },
{ name = "coins", label = "Coins", symbol = "C", hasDecimal = false, defaultAmount = 0 },
},
CommandPermissions = {
AddCurrency = { "admin" },
SetCurrency = { "admin" },
RemoveCurrency = { "admin" },
},
}The permissions addon is an optional dependency. When it's loaded, currency registers the addCurrency, setCurrency, and removeCurrency console/F8 commands; access is gated by CommandPermissions[<key>], which lists the groups allowed to run the command. Console (source = 0) always passes. Putting "user" in a gate list opens the command to every connected player, since user is the implicit default group every player has.
hasDecimal = true stores the amount in cents internally and exposes it as a float with 2 decimal places. defaultAmount > 0 seeds new players (any player without a row for that currency) with the value on next load. Rows are never pre-created on get; an unset currency reads as 0.
API on the player:
player:getBalance("money") -- 0 if never set
player:setBalance("money", 100.50)
player:addBalance("money", 5.00)
player:removeBalance("money", 2.50)
player:hasBalance("money", 50) -- boolServer emits server:balanceChanged on its own emitter with (player, name, oldAmount, newAmount) for any addon that wants to react.
Schema: single sidecar table currency_balances(license, currency_id, balance) with composite PK and FK to bonsai_players. Balance is stored in the currency's smallest unit (cents for decimal currencies, whole units otherwise).
A few requirements addon authors must respect, beyond the style guide:
server:playerLoadedhandlers must be idempotent. On arestart bonsaiwith players still connected, the core reconciles existing players by re-firingserver:playerLoadedfor each. An addon that grants a sign-on bonus or inserts a row on every event will double-charge. Guard with "already-loaded for this session" state, or use UPSERTs with conflict handling.onClientSyncproviders should be cheap and side-effect-free. They run inside the bootstrap callback for every connecting player. Reads are fine; writes belong inserver:playerLoaded.- Save handlers (
addon:onSave) should be pure writers. Don't call setters from inside them; the save scheduler clears dirty flags after handlers succeed, and re-entrancy can lose state. - Net handlers validate at the boundary (see style guide). The framework provides nothing here; the addon is responsible for type, range, and authorization checks on every parameter from the client.
bonsai/config.lua controls enabled addons and a few framework tunables:
return {
autosaveIntervalMs = 60000, -- save scheduler interval
mysqlReadyTimeoutMs = 30000, -- how long to wait for oxmysql before warning
playerBootstrapTimeoutMs = 5000, -- max wait for a connecting player to land in the registry
addons = {
"currency",
-- "identity",
-- ...
},
}Pre-release. The core boots cleanly with zero addons enabled. currency is the first first-party addon. Identity, characters, inventory, and permissions are planned.
See LICENSE.


