PlaceholderAPI
The papi module is a safe bridge to PlaceholderAPI. It resolves placeholders in your text, lets you register your own expansions declaratively, and - crucially - degrades cleanly when PlaceholderAPI is not installed. It is a softdepend integration: your plugin works with or without PlaceholderAPI present, and never crashes because of its absence.
Reach it through the context:
SnPapi papi = sn.papi();Resolving placeholders
apply(viewer, text) resolves PAPI tokens in a string. A null viewer resolves against the
server (so %server_online% works); a player viewer resolves per-player.
String raw = "&aHi %player_name%, %server_online% online";
String resolved = sn.papi().apply(player, raw);The important guarantee: when PlaceholderAPI is not installed, apply returns the
string untouched. It does not throw, and it never triggers a NoClassDefFoundError.
Why isolation matters for a softdepend
Every bytecode reference to a PlaceholderAPI class lives in an internal, isolated bridge
class that is only loaded after a runtime presence probe succeeds. The public SnPapi
class holds no PlaceholderAPI reference at all. This is the pattern that makes a softdepend
safe:
- If the JVM eagerly touched a PAPI type on a server without the plugin, the classloader
would raise
NoClassDefFoundErrorthe moment your code ran - even inside atry/catch, because the linkage happens before your guard. - By quarantining the PAPI references behind the probe, the class is never linked unless
PlaceholderAPI is actually present. The
try/catcharound your feature is real, not a trap that fires at class-load time.
If PlaceholderAPI is present but somehow becomes inaccessible mid-call (a LinkageError),
the module marks itself degraded, logs one warning, and falls back to returning the text
untouched.
There is a list overload too, resolving line by line:
List<String> lore = sn.papi().apply(player, rawLoreLines);Resolving from async code: applyOnMain
PlaceholderAPI resolution is main-thread only. If you call apply off the main thread, the
tokens are left untouched and the skip is noted through the debug service. When you are
async (a database callback, a Discord webhook, a leaderboard build) and you need real
resolution, use applyOnMain, which hops to the main thread and returns an SnFuture:
// inside an async callback
sn.papi().applyOnMain(viewer, "%vault_eco_balance% coins")
.thenSync(text -> board.setLine(0, text));On the main thread it resolves inline and returns an already-completed future; off it, it schedules the resolution and completes when done. It is fail-open: if resolution throws, or if your plugin disables before the main-thread hop can run, the future completes with the original, unresolved text rather than failing. Null text completes with null.
A list overload resolves an entire list in a single main-thread hop:
sn.papi().applyOnMain(viewer, rawLines)
.thenSync(lines -> hologram.setLines(lines));applyOnMain returns the same SnFuture type as the database module, so
you consume it the same way: thenSync to hop the result back for Bukkit calls,
exceptionally to observe a failure. Its canonical consumption is thenSync.
Never wait on it. When you call it off the main thread, only the main-thread hop it just
scheduled can complete the future, so a join() from the main thread would wait for work
that same thread was supposed to run. Since 1.27.0 that throws instead of deadlocking the
server silently.
Declarative custom expansions
Register your own placeholders without writing a PlaceholderExpansion subclass. Start
with expansion(identifier), bind resolvers, and register().
sn.papi().expansion("shop")
.placeholder("balance", player ->
String.valueOf(economy.balanceOf(player.getUniqueId())))
.placeholder("items_sold", player ->
String.valueOf(stats.itemsSold(player.getUniqueId())))
.register();That yields %shop_balance% and %shop_items_sold%.
placeholder(param, resolver)binds an exact token. The resolver receives the requestingOfflinePlayer.prefixed(prefix, resolver)binds every token starting withprefix; the resolver receives the remainder after the prefix as its second argument. Exact placeholders win over prefixed ones.
sn.papi().expansion("shop")
.placeholder("balance", player -> money(player))
.prefixed("price_", (player, item) -> String.valueOf(catalog.priceOf(item)))
.register();
// %shop_balance% -> exact
// %shop_price_diamond% -> prefixed, item = "diamond"author(...) and version(...) default to your plugin's plugin.yml values; override
them if you want.
Placeholders that need no player
Not every placeholder describes a player. A leaderboard row, a server-wide counter, an
event countdown - that is global data that merely happens to be exposed through a
per-player API. And the callers that ask for it often supply no player at all: a
global hologram, a Discord bridge, a console task, /papi parse --null.
placeholder(...) and prefixed(...) never hand their resolver a null player - the token
is left unresolved instead, so a resolver written against a real player stays safe.
Declare the global ones with global(...) / globalPrefixed(...) and they answer a null
requester too. The resolver takes no OfflinePlayer, so it cannot dereference one:
sn.papi().expansion("shop")
.placeholder("balance", player -> money(player)) // needs a player
.global("open_shops", () -> String.valueOf(registry.openCount()))
.globalPrefixed("top_", position -> topSellerAt(position))
.register();
// %shop_balance% -> empty for a null requester
// %shop_open_shops% -> answers anyone, player or not
// %shop_top_1% -> answers anyone, position = "1"Both share their keyspace with the player-bound binders, so declaring the same param twice is not an error: the later declaration wins outright.
LeaderboardCache.exposePlaceholders(id) already binds its top_ tokens this way, so
every board you expose through it renders in a global hologram. pos_<id> asks about the
requester by definition and stays player-bound.
persist true semantics
The built expansion reports persist() = true to PlaceholderAPI. That means it survives
a PlaceholderAPI expansion reload (/papi reload) and is removed only by your plugin's
own context teardown - you do not have to re-register it after an admin reloads PAPI.
register() also does a lookup-before-register: it unregisters any previous expansion
under the same identifier first, so a second enable of your plugin (a reload, a PlugMan
re-add) never fails with a "duplicate expansion" error. It returns false with a warning
when PlaceholderAPI is absent or the registration is rejected.
Resolvers run on the main thread inside PlaceholderAPI's parse. They must read
precomputed in-memory state only - never disk, database, or network. There is no supported
async resolver: precompute a cache (for example a leaderboard snapshot) and resolve with
fast, lock-free reads. For the inverse direction - composing text that contains PAPI
tokens from an async flow - use applyOnMain.
Reactive to live install and removal
The presence probe is cached, but the bridge reacts to PlaceholderAPI toggling on a running server. If PlaceholderAPI is enabled or disabled live (installed via a plugin manager, or removed), the bridge invalidates its cached probe and re-checks on the next call - activating itself when PAPI appears and going dormant (fail-open, text untouched) when it disappears. No server restart is required either way.
You can also drop the cached probe manually with sn.papi().invalidate(), and query the
current state with sn.papi().available().
See also
- Language - lang messages run through the same PAPI resolution before render.
- Text - the pipeline PAPI output feeds into for colors and formatting.
- Database - the
SnFuturetypeapplyOnMainshares. - Quickstart and the developer overview.