Designing Plugin Architectures in Lua
A plugin architecture lets other developers extend your application without touching your core code. Lua’s module system makes this straightforward — a plugin is just a table returned by a module, and your host application calls into it.
Plugin as Module
The simplest plugin is a Lua file that returns a table of functions:
-- plugins/my_plugin.lua
local M = {}
function M.on_load(app)
print("plugin loaded:", app.name)
end
function M.on_enable(app)
print("plugin enabled")
end
return M
Your host application loads it via require:
local plugin = require("plugins.my_plugin")
plugin.on_load({ name = "MyApp" })
The plugin returns a table. The host decides when and how to call it.
Hook System
Most plugin systems work around hooks — named callbacks that the host calls at specific points. Define your hook registry:
local Hooks = {}
function Hooks.register(name, callback, priority)
if not Hooks[name] then
Hooks[name] = {}
end
table.insert(Hooks[name], {
callback = callback,
priority = priority or 100,
})
table.sort(Hooks[name], function(a, b)
return a.priority < b.priority
end)
end
function Hooks.invoke(name, ...)
if not Hooks[name] then return end
for _, hook in ipairs(Hooks[name]) do
hook.callback(...)
end
end
Lower priority numbers run first. Plugins that need to run before others set a lower number.
Register a hook from a plugin:
local M = {}
function M.on_load(app)
app.hooks.register("player_damaged", function(player, amount)
print(player.name, "took", amount, "damage")
end, 50)
end
return M
Invoke hooks from the host:
function Game:damage_player(player, amount)
player.health = player.health - amount
self.app.hooks.invoke("player_damaged", player, amount)
end
Lifecycle Hooks
Plugins need to know when they’re being loaded, enabled, disabled, or unloaded. Define standard lifecycle hooks:
local PluginHost = {
plugins = {},
hooks = Hooks,
}
function PluginHost.load_plugin(name, path)
local plugin_path = path .. "/" .. name .. ".lua"
local plugin = require(path .. "." .. name)
plugin._name = name
plugin._enabled = false
self.plugins[name] = plugin
if plugin.on_load then
plugin:on_load(self)
end
return plugin
end
function PluginHost.enable_plugin(name)
local plugin = self.plugins[name]
if not plugin then return end
if plugin._enabled then return end
if plugin.on_enable then
plugin:on_enable(self)
end
plugin._enabled = true
end
function PluginHost.disable_plugin(name)
local plugin = self.plugins[name]
if not plugin or not plugin._enabled then return end
if plugin.on_disable then
plugin:on_disable(self)
end
plugin._enabled = false
end
function PluginHost.unload_plugin(name)
local plugin = self.plugins[name]
if not plugin then return end
self:disable_plugin(name)
if plugin.on_unload then
plugin:on_unload(self)
end
self.plugins[name] = nil
end
Not every plugin needs every lifecycle hook. Check if the method exists before calling it.
Plugin Registry
Track what’s loaded and enabled:
local Registry = {
loaded = {},
enabled = {},
}
function Registry.register(plugin)
self.loaded[plugin._name] = plugin
end
function Registry.enable(name)
if self.enabled[name] then return end
self.enabled[name] = true
end
function Registry.disable(name)
self.enabled[name] = nil
end
function Registry.is_enabled(name)
return self.enabled[name] ~= nil
end
function Registry.list_enabled()
local list = {}
for name, _ in pairs(self.enabled) do
table.insert(list, name)
end
return list
end
Sandboxed Plugin Loading
Plugins you didn’t write shouldn’t have access to everything. Restrict the environment:
function PluginHost.load_plugin_sandboxed(name, path)
local plugin_path = path .. "/" .. name .. ".lua"
local env = {
print = print,
pairs = pairs,
ipairs = ipairs,
table = table,
string = string,
math = math,
type = type,
pcall = pcall,
error = error,
-- restrict os and io
os = {
clock = os.clock,
time = os.time,
date = os.date,
},
}
setmetatable(env, { __index = _G })
local chunk, err = loadfile(plugin_path, nil, env)
if not chunk then
return nil, "failed to load: " .. err
end
local ok, plugin = pcall(chunk)
if not ok then
return nil, "plugin error: " .. tostring(plugin)
end
return plugin
end
The plugin can’t reach io.open, os.execute, or debug unless you explicitly expose them. Keep your host’s internals private by not putting them in the sandbox’s environment.
Plugin Configuration
Plugins often need configuration. Let them declare what they need:
-- plugins/analytics_plugin.lua
local M = {}
M.config_schema = {
api_key = { type = "string", required = true },
endpoint = { type = "string", default = "https://api.example.com" },
batch_size = { type = "number", default = 10 },
}
function M.on_load(app)
local config = app.config:get_plugin_config("analytics", M.config_schema)
app.hooks.register("event", function(event)
-- send to analytics
end)
end
return M
The host validates and provides config. The plugin declares what it needs.
Auto-Discovery
Load all plugins from a directory automatically:
function PluginHost.discover_plugins(plugins_dir)
local handle = io.popen("ls " .. plugins_dir .. "/*.lua")
if not handle then return end
for filename in handle:lines() do
local name = filename:match("([^/]+)%.lua$")
if name and name ~= "init" then
local ok, err = pcall(function()
self:load_plugin(name, plugins_dir)
end)
if not ok then
print("failed to load plugin", name, err)
end
end
end
handle:close()
end
In production, use lfs.attributes or pl.dir.dirlist instead of spawning ls. On OpenResty, ngx.location.capture with a virtual path works.
Cross-Plugin Communication
Plugins shouldn’t call each other directly — that creates tight coupling. Instead, use an event bus mediator:
local EventBus = {
listeners = {},
}
function EventBus.subscribe(event, callback)
if not self.listeners[event] then
self.listeners[event] = {}
end
table.insert(self.listeners[event], callback)
end
function EventBus.publish(event, ...)
if not self.listeners[event] then return end
for _, cb in ipairs(self.listeners[event]) do
cb(...)
end
end
-- In a plugin:
EventBus.subscribe("player_level_up", function(player, new_level)
-- grant achievements, notify friends, etc.
end)
Plugins communicate through events, not direct calls. The host publishes; plugins subscribe.
Priority and Ordering
Some plugins need to run before others. Numeric priority handles this:
Hooks.register("init", function()
print("this runs first")
end, 1)
Hooks.register("init", function()
print("this runs second")
end, 10)
Lower numbers execute first. Reserve low numbers (1-99) for core plugins and use higher numbers for optional ones.
Common Mistakes
No error handling around plugin calls. If a plugin errors inside a hook, it can crash your host. Wrap hook invocations in pcall:
function Hooks.invoke(name, ...)
if not Hooks[name] then return end
for _, hook in ipairs(Hooks[name]) do
local ok, err = pcall(hook.callback, ...)
if not ok then
print("hook error in", name, err)
end
end
end
Assuming plugins load in a specific order. Use explicit dependencies if ordering matters, or have plugins register priority callbacks to sequence themselves.
Leaking state between plugin instances. Each plugin should initialize its own state in on_load, not at module level. Module-level tables are shared across all plugin instances.
See Also
- /guides/lua-sandboxing/ — restrict what plugins can access
- /guides/lua-event-systems/ — event patterns for decoupled communication
- /guides/lua-config-files/ — managing application and plugin configuration