Adapter Patterns: ESX, QBCore & QBOX (Exports, Events & APIs)
Step-by-step tutorial on how to . Includes stubs catches mismatches before you deploy. Complete guide for 2026.

Introduction to This is a FiveM Framework Adapter – for scripters
This is a FiveM Framework Adapter – for scripters. Ship one resource that runs on ESX, QBCore, and QBOX by isolating framework‑specific calls behind a thin adapter. Drop the shared/fw.lua and per‑framework adapters below into any resource, call the stable interface contract (FW.Player, FW.Job, FW.Money, FW.Inv, FW.Events), and keep business logic framework‑agnostic. A small test matrix with stubs catches mismatches before you deploy.
Why an Adapter?
Framework differences cluster around the same seams:
- Core access (ESX
getSharedObject, QBCoreGetCoreObject, QBOX exports only) - Player model (xPlayer vs Player/PlayerData)
- Identifiers (license/steam vs citizenid)
- Money & inventory APIs
- Event names at load/login/job‑update time
A unified interface keeps these seams out of your game logic. You swap the adapter, not the codebase.
BTW: You can use our written adapter here, for free:
How to Use (Drop‑in)
Tree (suggested):
my-resource/ ├─ fxmanifest.lua ├─ shared/ │ ├─ adapters/ │ │ ├─ esx.lua │ │ ├─ qb.lua │ │ └─ qbox.lua │ └─ fw.lua ├─ server/ │ └─ main.lua └─ client/ └─ main.lua
fxmanifest.lua (load adapters first, then fw.lua so detection can bind):
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
shared_scripts { lua
'shared/adapters/*.lua',
'shared/fw.lua'
}
client\_scripts { ```lua
'client/*.lua'
}
server_scripts { lua
'@oxmysql/lib/MySQL.lua', -- optional: if you use SQL
'server/*.lua'
}
**In your code** (server or client):
```lua
```lua
-- use the stable interface everywhere
local src = source
local p = FW.Player.getBySrc(src)
local job = FW.Job.getName(p)
FW.Money.add(p, 'cash', 250, 'delivery-bonus')
FW.Inv.addItem(p, 'water', 1)
FW.Events.notify(src, 'Job bonus paid.', 'success')
> The only symbol you depend on is `FW`. Everything else is internal to the **adapters**.
* * *
## Interface Contract (stable surface)
Design goal: **Small, explicit, documented.** These are the functions you can rely on across frameworks.
### `FW.meta`
* `name() -> 'esx'|'qbcore'|'qbox'`
* `has(resourceName: string) -> boolean` (resource started?)
### `FW.Player`
* `getBySrc(src: number) -> any` (framework player handle)
* `getStateId(p) -> string` (ESX: identifier; QB/QBOX: citizenid)
* `getServerId(p) -> number` (numeric id)
* `getName(p) -> string`
### `FW.Job`
* `getName(p) -> string`
* `getGrade(p) -> number|string`
* `onChange(handler(src, oldJob, newJob))` (fires when job changes, if detectable)
### `FW.Money`
* `get(p, account: 'cash'|'bank'|'black_money'?) -> number`
* `add(p, account, amount: number, reason?: string)`
* `remove(p, account, amount: number, reason?: string)`
### `FW.Inv` (best‑effort; see Notes)
* `addItem(p, name: string, count: number, metadata?: table) -> boolean`
* `removeItem(p, name: string, count: number, metadata?: table) -> boolean`
> ## Inventory note: servers vary (qb-inventory,
>
> **Inventory note:** servers vary (qb-inventory, ox\_inventory, qs‑inventory, etc.). The default implementation uses framework inventory when available and falls back to `ox_inventory` if detected.
### `FW.Events`
* `notify(target: number, msg: string, type?: 'info'|'success'|'error')`
* `onPlayerLoaded(handler(src))` (best‑effort, with fallback via `playerJoining`)
* * *
## Drop‑in Adapters (copy/paste)
These are pragmatic defaults. If your fork differs (especially for QBOX), adjust the few marked comments.
### `shared/fw.lua`
```lua
```lua
-- framework bridge bootstrap
FW = FW or {}
```lua
```lua
local function started(name)
local st = GetResourceState(name)
return st == 'started' or st == 'starting'
end
local which
if started('qbx_core') then which = 'qbox'
elseif started('qb-core') then which = 'qbcore'
elseif started('es_extended') then which = 'esx' end
if which == 'qbcore' then
FW = Adapters.qb()
```lua
```lua
elseif which == 'qbox' then
FW = Adapters.qbox()
```lua
```lua
elseif which == 'esx' then
FW = Adapters.esx()
```lua
```lua
else
error('\[FW\] No supported framework found (es\_extended / qb-core / qbx\_core).')
```lua
```lua
end
-- tiny helpers common to all adapters
function FW.meta.has(res)
return started(res)
end
### `shared/adapters/esx.lua`
Adapters = Adapters or {}
Adapters.esx = function()
```lua
```lua
local ESX = exports['es_extended']:getSharedObject()
local M = {
meta = { name = function() return 'esx' end },
Player = {}, Job = {}, Money = {}, Inv = {}, Events = {}
}
-- Player
function M.Player.getBySrc(src) return ESX.GetPlayerFromId(src) end
function M.Player.getStateId(p) return p.identifier end
function M.Player.getServerId(p) return p.source end
function M.Player.getName(p) return p.getName and p.getName() or GetPlayerName(p.source) end
-- Job
function M.Job.getName(p) return (p.getJob and p.getJob().name) or (p.job and p.job.name) end
function M.Job.getGrade(p)
local j = p.getJob and p.getJob() or p.job
return j and (j.grade or (j.grade and j.grade.grade))
end
function M.Job.onChange(handler)
-- ESX fires when job changes (commonly 'esx:setJob')
RegisterNetEvent('esx:setJob', function(job)
local src = source
handler(src, nil, job and job.name)
end)
end
-- Money
local function norm(account) return account == 'cash' and 'money' or account end
function M.Money.get(p, account)
account = norm(account)
if account == 'money' then return p.getMoney() end
local acc = p.getAccount and p.getAccount(account)
return acc and acc.money or 0
end
function M.Money.add(p, account, amount)
account = norm(account)
if account == 'money' then p.addMoney(amount) else p.addAccountMoney(account, amount) end
end
function M.Money.remove(p, account, amount)
account = norm(account)
if account == 'money' then p.removeMoney(amount) else p.removeAccountMoney(account, amount) end
end
-- Inventory (ESX native, with ox fallback)
local hasOX = GetResourceState('ox_inventory') == 'started'
if hasOX then
function M.Inv.addItem(p, name, count, meta) return exports.ox_inventory:AddItem(p.source, name, count, meta) end
function M.Inv.removeItem(p, name, count, meta) return exports.ox_inventory:RemoveItem(p.source, name, count, meta) end
else
function M.Inv.addItem(p, name, count) p.addInventoryItem(name, count); return true end
function M.Inv.removeItem(p, name, count) p.removeInventoryItem(name, count); return true end
end
-- Events
function M.Events.notify(target, msg, kind)
kind = kind or 'info'
-- Implement your UI notify event here. Example placeholder:
TriggerClientEvent('fw:notify', target, msg, kind)
end
function M.Events.onPlayerLoaded(handler)
RegisterNetEvent('esx:playerLoaded', function(_)
handler(source)
end)
end
return M
end
### `shared/adapters/qb.lua` (QBCore)
Adapters = Adapters or {}
Adapters.qb = function()
```lua
```lua
local QBCore = exports['qb-core']:GetCoreObject()
local M = {
meta = { name = function() return 'qbcore' end },
Player = {}, Job = {}, Money = {}, Inv = {}, Events = {}
}
-- Player
function M.Player.getBySrc(src) return QBCore.Functions.GetPlayer(src) end
function M.Player.getStateId(p) return p.PlayerData.citizenid end
function M.Player.getServerId(p) return p.PlayerData.source end
function M.Player.getName(p)
local pd = p.PlayerData
return (pd.charinfo and (pd.charinfo.firstname .. ' ' .. pd.charinfo.lastname)) or GetPlayerName(pd.source)
end
-- Job
function M.Job.getName(p) return p.PlayerData.job.name end
function M.Job.getGrade(p)
local g = p.PlayerData.job.grade
return type(g) == 'table' and (g.level or g.grade) or g
end
function M.Job.onChange(handler)
-- QBCore client event relays job update; mirror serverside via simple relay if needed.
RegisterNetEvent('QBCore:Server:OnJobUpdate', function(job)
handler(source, nil, job and job.name)
end)
end
-- Money
function M.Money.get(p, account) return p.PlayerData.money[account] or 0 end
function M.Money.add(p, account, amount, reason) p.Functions.AddMoney(account, amount, reason or 'fw') end
function M.Money.remove(p, account, amount, reason) p.Functions.RemoveMoney(account, amount, reason or 'fw') end
-- Inventory (qb-inventory or ox)
local hasOX = GetResourceState('ox_inventory') == 'started'
if hasOX then
function M.Inv.addItem(p, name, count, meta) return exports.ox_inventory:AddItem(p.PlayerData.source, name, count, meta) end
function M.Inv.removeItem(p, name, count, meta) return exports.ox_inventory:RemoveItem(p.PlayerData.source, name, count, meta) end
else
function M.Inv.addItem(p, name, count, meta) return p.Functions.AddItem(name, count, false, meta) end
function M.Inv.removeItem(p, name, count) return p.Functions.RemoveItem(name, count) end
end
-- Events
function M.Events.notify(target, msg, kind)
TriggerClientEvent('fw:notify', target, msg, kind or 'info')
end
function M.Events.onPlayerLoaded(handler)
RegisterNetEvent('QBCore:Server:PlayerLoaded', function()
handler(source)
end)
end
return M
end
### `shared/adapters/qbox.lua` (QBOX / qbx\_core)
Adapters = Adapters or {}
Adapters.qbox = function()
```lua
```lua
-- QBOX typically exposes functions via exports only.
-- If your fork also ships a GetCoreObject, swap accordingly.
local QBX = exports['qbx_core']
local M = {
meta = { name = function() return 'qbox' end },
Player = {}, Job = {}, Money = {}, Inv = {}, Events = {}
}
-- Player (QBOX uses Player with PlayerData similar to QBCore)
function M.Player.getBySrc(src) return QBX:GetPlayer(src) end -- adjust if your API differs
function M.Player.getStateId(p) return p.PlayerData.citizenid end
function M.Player.getServerId(p) return p.PlayerData.source end
function M.Player.getName(p)
local pd = p.PlayerData
return (pd.charinfo and (pd.charinfo.firstname .. ' ' .. pd.charinfo.lastname)) or GetPlayerName(pd.source)
end
-- Job
function M.Job.getName(p) return p.PlayerData.job.name end
function M.Job.getGrade(p)
local g = p.PlayerData.job.grade
return type(g) == 'table' and (g.level or g.grade) or g
end
function M.Job.onChange(handler)
-- Some QBOX builds forward QBCore job events; if not, wire your own when setting jobs.
RegisterNetEvent('QBCore:Server:OnJobUpdate', function(job)
handler(source, nil, job and job.name)
end)
end
-- Money
function M.Money.get(p, account) return p.PlayerData.money[account] or 0 end
function M.Money.add(p, account, amount, reason)
if p.Functions and p.Functions.AddMoney then p.Functions.AddMoney(account, amount, reason or 'fw')
else QBX:AddMoney(p.PlayerData.source, account, amount, reason or 'fw') end
end
function M.Money.remove(p, account, amount, reason)
if p.Functions and p.Functions.RemoveMoney then p.Functions.RemoveMoney(account, amount, reason or 'fw')
else QBX:RemoveMoney(p.PlayerData.source, account, amount, reason or 'fw') end
end
-- Inventory (ox preferred on many QBOX servers)
local hasOX = GetResourceState('ox_inventory') == 'started'
if hasOX then
function M.Inv.addItem(p, name, count, meta) return exports.ox_inventory:AddItem(p.PlayerData.source, name, count, meta) end
function M.Inv.removeItem(p, name, count, meta) return exports.ox_inventory:RemoveItem(p.PlayerData.source, name, count, meta) end
else
-- fall back to qb-style if present
if p and p.Functions and p.Functions.AddItem then
function M.Inv.addItem(p, name, count, meta) return p.Functions.AddItem(name, count, false, meta) end
function M.Inv.removeItem(p, name, count) return p.Functions.RemoveItem(name, count) end
else
function M.Inv.addItem() return false end
function M.Inv.removeItem() return false end
end
end
-- Events
function M.Events.notify(target, msg, kind)
TriggerClientEvent('fw:notify', target, msg, kind or 'info')
end
function M.Events.onPlayerLoaded(handler)
-- Some QBOX builds reuse QBCore load events; if yours differs, relay from your login logic.
RegisterNetEvent('QBCore:Server:PlayerLoaded', function()
handler(source)
end)
end
return M
end
* * *
## Usage Examples
### 1) Paying a job bonus
```lua
```lua
RegisterNetEvent('myres:payBonus', function()
local src = source
local p = FW.Player.getBySrc(src)
if not p then return end
if FW.Job.getName(p) == 'delivery' then
FW.Money.add(p, 'cash', 250, 'delivery-bonus')
FW.Events.notify(src, 'Bonus paid (+$250).', 'success')
else
FW.Events.notify(src, 'You are not on duty as Delivery.', 'error')
end
end)
### 2) Inventory grant with ox fallback already handled
```lua
```lua
local function giveStarter(src)
local p = FW.Player.getBySrc(src)
if p then FW.Inv.addItem(p, 'water', 2) end
end
FW.Events.onPlayerLoaded(giveStarter)
* * *
## Anti‑Pattern Catalog (and Fixes)
Anti‑pattern
Why it bites
Fix with adapter
**Hard‑coding core object** (`ESX = exports['es_extended']:getSharedObject()` scattered everywhere)
Locks you into ESX, tedious to migrate
Only call `FW.*`. Core resolution lives in adapter.
**Storing framework player handle long‑term** (e.g., keep `xPlayer` in a table forever)
Handles can go stale; references differ per framework
Re‑fetch via `FW.Player.getBySrc(src)` when you act, or cache by `getStateId` key and re‑resolve.
**Assuming identifiers** are the same (ESX `identifier` vs QB/QBOX `citizenid`)
Breaks DB relations/migrations
Use `FW.Player.getStateId(p)` and a crosswalk table during migrations.
**Direct event names in business logic** (`esx:playerLoaded`, `QBCore:Server:PlayerLoaded`)
Fragile across forks
Subscribe via `FW.Events.onPlayerLoaded`.
**Mixed inventory assumptions**
Servers swap inventories often
Use `FW.Inv.*` which detects `ox_inventory` first, then framework.
**SQL schemas frozen to one framework**
`accounts`, `identifier`, etc. diverge
Use neutral columns (`state_id`, `money_cash`, `money_bank`) and migration helpers below.
* * *
## SQL & Identifier Migration Notes (Quick Reference)
* **Primary person key:**
* ESX → `identifier` (license/steam)
* QB/QBOX → `citizenid`
* **Neutral key in your tables:** `state_id` (string). Store `FW.Player.getStateId(p)`.
* **Money:**
* ESX: `money` (cash), `accounts.bank`, `accounts.black_money`
* QB/QBOX: `PlayerData.money.cash|bank`
* **Minimal crosswalk (one‑time backfill):**
```sql
```sql
-- Example: populate your neutral key from ESX users
UPDATE my_table t
JOIN users u ON u.identifier = t.identifier
SET t.state_id = u.identifier
WHERE t.state_id IS NULL;
-- Example: migrate to QB/QBOX where you have a mapping table esx_identifier→citizenid
UPDATE my_table t
JOIN id_map m ON m.esx_identifier = t.state_id
SET t.state_id = m.citizenid
WHERE m.citizenid IS NOT NULL;
> ## Keep the crosswalk ( id\_map ) only during the
>
> Keep the crosswalk (`id_map`) only during the transition; future writes should always use `state_id`.
* * *
```lua
## Testing Matrix & CI: Validate a Script Across Frameworks
You don’t need to boot a full CFX server in CI to catch most adapter issues. **Stub exports** and run unit tests for the contract surface.
```lua
### 1) Minimal test (Busted)
tests/fw_spec.lua
local function makeStub(framework)
_G.Adapters = {}
if framework == 'esx' then
_G.exports = { ['es_extended'] = { getSharedObject = function()
return {
GetPlayerFromId = function(src)
return {source = src, identifier = 'license:abc', getMoney = function() return 100 end,
addMoney = function() end, removeMoney = function() end,
getJob = function() return {name='mechanic', grade=2} end,
addAccountMoney=function() end, removeAccountMoney=function() end,
addInventoryItem=function() end, removeInventoryItem=function() end,
getName=function() return 'Alex ESX' end }
end
}
end } }
_G.GetResourceState = function(n) return n=='es_extended' and 'started' or 'missing' end
dofile('shared/adapters/esx.lua')
elseif framework == 'qbcore' then
_G.exports = { ['qb-core'] = { GetCoreObject = function()
return { Functions = { GetPlayer=function(src)
return { PlayerData={source=src,citizenid='CITZ123',job={name='mechanic',grade=2},
money={cash=100,bank=500},charinfo={firstname='Alex',lastname='QB'}},
Functions={AddMoney=function() end, RemoveMoney=function() end, AddItem=function() return true end, RemoveItem=function() return true end} }
end } }
end } }
_G.GetResourceState = function(n) return n=='qb-core' and 'started' or 'missing' end
dofile('shared/adapters/qb.lua')
elseif framework == 'qbox' then
_G.exports = { ['qbx_core'] = setmetatable({}, { __index = function()
return function(name) end
end }) }
_G.GetResourceState = function(n) return n=='qbx_core' and 'started' or 'missing' end
dofile('shared/adapters/qbox.lua')
end
dofile('shared/fw.lua')
end
describe('FW contract', function()
it('resolves player and money (esx)', function()
makeStub('esx')
assert.are.equal('esx', FW.meta.name())
local p = FW.Player.getBySrc(1)
assert.are.equal('license:abc', FW.Player.getStateId(p))
assert.are.equal(100, FW.Money.get(p, 'cash'))
end)
it('resolves player and money (qbcore)', function()
makeStub('qbcore')
assert.are.equal('qbcore', FW.meta.name())
local p = FW.Player.getBySrc(2)
assert.are.equal('CITZ123', FW.Player.getStateId(p))
assert.are.equal(100, FW.Money.get(p, 'cash'))
end)
end)
### 2) GitHub Actions (luacheck + busted)
.github/workflows/lua.yml
name: Lua CI
on: [push, pull_request]
jobs: test: runs-on: ubuntu-latest strategy: matrix:
lua: [ '5.4' ]
steps:
- uses: actions/checkout@v4
- name: Install Lua & LuaRocks
```lua
uses: leafo/gh-actions-lua@v10
```lua
with: { luaVersion: ${{ matrix.lua }} }
- name: Install rocks
```lua
uses: leafo/gh-actions-luarocks@v4
- run: luarocks install luacheck
- run: luarocks install busted
- name: Lint
run: luacheck . --no-color --codes
- name: Test
run: busted -v
```lua
## .luacheckrc (baseline)
**.luacheckrc** (baseline)
std = 'lua54'
unused_args = false
max_line_length = 140
ignore = { '211', '212' } -- adjust for your style
> For full integration tests, spin up your dev server once and smoke‑test with a tiny command set. CI stubs are sufficient to catch surface breaks.
* * *
```lua
## Implementation Checklist
- Drop
shared/adapters/*.luaandshared/fw.luainto your resource - Replace all direct ESX/QBCore/QBOX calls in your code with
FW.* - Keep only one persistence key:
state_idin your tables - Configure inventory preference (ox first by default)
- Add CI (luacheck + busted) and a minimal test for each call you use
- Document any local deviations (fork‑specific events) at the top of your adapter file
```lua
## Frequently Extended Surface (optional add‑ons)
* `FW.Duty.set(p, true|false)` – wrap your duty toggles
* `FW.Permissions.has(src, aceOrGroup)` – centralize admin/group checks
* `FW.Vehicle.spawn(model, coords)` – hide framework spawn helpers
```lua
```lua
Keep the **core** contract tiny; put optional helpers in a separate module.
* * *
```lua
## Tri‑way Mapping Quick Table
Concern
```lua
ESX
QBCore
QBOX (typical)
Core access
`exports['es_extended']:getSharedObject()`
`exports['qb-core']:GetCoreObject()`
_No global;_ exports on `qbx_core`
Player by src
`ESX.GetPlayerFromId(src)`
`QBCore.Functions.GetPlayer(src)`
`exports.qbx_core:GetPlayer(src)` _(adjust if forked)_
Identifier
xPlayer.identifier
PlayerData.citizenid
PlayerData.citizenid
Money add
addMoney / addAccountMoney
Functions.AddMoney
Functions.AddMoney or exports.qbx_core:AddMoney
Job name
xPlayer.job.name
PlayerData.job.name
PlayerData.job.name
Player loaded event
esx:playerLoaded
QBCore:Server:PlayerLoaded
Often reuses QBCore events; fork‑specific
> When in doubt on QBOX, **inspect your fork’s `qbx_core` exports** and wire accordingly.
* * *
```lua
## Final Notes
- Keep adapters boring: no side effects, no database calls.
- Treat framework handles as opaque; extract what you need through the contract.
- When you must diverge for a client, copy the adapter, not your business logic.
```lua
**Read next:** [Converting FiveM Scripts Between ESX, QBCore & QBOX (Pillar Page)](https://vertexmods.com/en/blog/converting-fivem-scripts/)```


