fomvasss / laravel-str-tokens
A package to manage and generate string with tokens/shortcodes for Eloquent Models
Package info
github.com/fomvasss/laravel-str-tokens
Type:composer-package
pkg:composer/fomvasss/laravel-str-tokens
Requires
- php: ^8.0.2
- illuminate/support: ^9|^10|^11|^12|^13
- nesbot/carbon: ^2|^3
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.0|^11.0
README
Replace [type:field]-style tokens/shortcodes in a string with real values pulled from Eloquent models, config, dates, or arbitrary variables — the same idea as Drupal's token system, built for Laravel.
Why
Any app that builds text from a template ends up writing the same glue code: notification/email bodies with
placeholders, admin-configurable message templates, CMS-style content blocks, SMS text with client data,
generated documents. This package turns that into one small, declarative API: write [order:title] in a
string, hand it an Order model, get the real title back — including nested relations, custom formatting
rules, and values that have nothing to do with a model at all (dates, config, arbitrary variables).
Features
[type:field]token resolution against any Eloquent model — no base class, no interface required- Nested relations:
[order:manager:email]walks themanager()relation and resolvesemailon the result, to any depth - Custom tokens per model via
strToken*()methods — full control, optional extra arguments ([chat:message:100]) - Formatters:
[user:name:uppercase]post-processes the resolved value; ships withuppercase,lowercase,trim,clearHtml,urlLink, plus your own - Multiple entities in one string via
setEntities(['user' => $user, 'order' => $order]) - Standalone token namespaces that don't need any model:
[var:...],[date:...],[config:...] - Access control: per-model
strTokenWhitelist()/strTokenBlacklist(), or a globaldisable_model_tokens/disable_configsconfig list — keep secrets likepasswordorapi_tokenout of reach even if a template author tries - Configurable token syntax (
token_split_character,token_match_pattern) if[type:field]doesn't fit your project - Ships as a Facade (
StrToken) and a container-resolvable class (StrTokenGenerator)
Requirements
- PHP ^8.0.2
- Laravel (
illuminate/support) ^9 – ^13
Installation
composer require fomvasss/laravel-str-tokens
The service provider and StrToken facade are auto-discovered. To customize the config, publish it:
php artisan vendor:publish --provider="Fomvasss\LaravelStrTokens\ServiceProvider"
This creates config/str-tokens.php.
Quick example
use Fomvasss\LaravelStrTokens\Facades\StrToken; $text = <<<TXT Order: [order:title] (#[order:id]) Status: [order:status] Manager: [order:manager:fullname] <[order:manager:email]> Channel: [order:channel:name] Support line: [var:supportPhone] Today: [date:date] TXT; $result = StrToken::setText($text) ->setEntity($order) ->setVar('supportPhone', '+380 44 000 0000') ->replace();
Order: Order #1042 (#1)
Status: Status: active
Manager: Taylor Otwell <taylor@laravel.com>
Channel: Telegram
Support line: +380 44 000 0000
Today: 17.08.2026
Core concepts
Token syntax
A token is [type:name]. type identifies what to resolve against (a model, or one of the built-in
namespaces below); name is everything after the first : — it may contain further colons, which is how
nested relations and formatters are expressed (manager:email, name:uppercase).
One entity: setEntity()
StrToken::setText('[order:title], placed by [order:manager:fullname]') ->setEntity($order) ->replace();
The token type must match Str::snake(class_basename($entity)) — an App\Models\Order instance is
addressed as [order:...], an App\Models\BlogPost as [blog_post:...]. If your token prefix can't (or
shouldn't) match the class name, use setEntities() instead — its keys are whatever you want them to be.
Multiple entities: setEntities()
StrToken::setText('Manager: [manager:fullname], Client: [client:fullname], Order: [order:title]') ->setEntities([ 'manager' => $manager, 'client' => $client, 'order' => $order, ]) ->replace(); // Manager: Taylor Otwell, Client: Vasyl Fomin, Order: Order #1042
The array key is the token prefix — it doesn't need to relate to the model's class name at all.
Don't combine
setEntity()andsetEntities(). CallingsetEntities()clears whateversetEntity()set before it; callingsetEntity()aftersetEntities()does not clearsetEntities(), andsetEntity()takes priority. Pick one perreplace()call.
Nested relations
Any real Eloquent relation method on the entity can be walked, to any depth — the relation method's name does not need to match the related model's class name:
// Order::manager() is a belongsTo(User::class) — name doesn't matter '[order:manager:fullname]' // walks two relations deep '[order:manager:company:name]' // a hasMany/BelongsToMany relation resolves against its FIRST related model '[order:comments:body]'
A relation that resolves to null (e.g. an unassigned belongsTo) simply produces an empty string —
no error.
Set can_traverse_relations => false in the config to disable this globally; a nested token then resolves
to an empty string instead of walking the relation (see Configuration).
Standalone tokens — no model required
| Token | Resolves to |
|---|---|
[var:name] |
A value set via setVar('name', $value) / setVars([...]). Missing key → ''. |
[date:format] |
The date set via setDate($carbon) (defaults to now()), formatted using date.formats.{format} from the config. [date:raw] returns the Carbon instance itself. |
[config:some.key] |
config('some.key'), unless it matches a disable_configs pattern. |
StrToken::setText('[var:price] — offer valid until [date:long] — app: [config:app.name]') ->setDate(now()->addDays(3)) ->setVar('price', '$49') ->replace();
Custom tokens: strToken*() methods
Define a method named Str::camel('str_token_' . $name) on the model to fully control how a token
resolves — e.g. [order:status] looks for strTokenStatus(). This takes priority over relation traversal
and plain field access.
class Order extends Model { // [order:status] -> "Status: active" public function strTokenStatus(): string { return 'Status: ' . $this->status; } }
Extra arguments — every :-separated segment of the token, including the method's own name, is passed
through as a string argument after the model itself. This is how you build a parameterized token:
class Chat extends Model { // [chat:lastMessage] -> full text // [chat:lastMessage:100] -> first 100 characters public function strTokenLastMessage(self $chat, string $key, ?string $limit = null): string { $text = strip_tags((string) $chat->lastMessage?->content); return $limit ? \Illuminate\Support\Str::limit($text, (int) $limit) : $text; } }
Arguments always arrive as strings (they come from exploding the raw token text) — type-hint them as
?string and cast inside the method, not as int/float, or a strict-typed parameter will throw.
Formatters
Append :formatterName as the last segment of a token to post-process its resolved value:
'[user:name:uppercase]' // "JOHN" '[user:email:lowercase]' '[user:bio:clearHtml]' // strip_tags() '[user:website:urlLink]' // wraps into <a href="...">...</a>, value is HTML-escaped
Built-in formatters (config('str-tokens.formatters')):
| Key | Effect |
|---|---|
trim |
PHP's trim() |
uppercase |
mb_strtoupper() |
lowercase |
mb_strtolower() |
clearHtml |
strip_tags() |
urlLink |
Wraps the (HTML-escaped) value in <a href='...'>...</a> |
Register your own — a formatter is any callable, an invokable class with a handle(?string $value): string
method, or a global function name:
// config/str-tokens.php 'formatters' => [ // ...defaults... 'money' => fn (?string $v) => number_format((float) $v, 2) . ' UAH', ],
'[order:total:money]'
Only one formatter per token — [user:name:uppercase:trim] does not chain both; the last segment
after the final : is looked up as-is. If you need to combine several transforms, write one custom
strToken*() method that does both (see the [chat:lastMessage:100] example above) rather than trying to
stack formatters.
Restricting what's exposed
Templates are often edited by someone other than a developer (an admin UI, a client-configurable notification). Three independent ways to keep sensitive fields out of reach:
class User extends Model { // Only these tokens resolve for this model — everything else is silently empty public function strTokenWhitelist(): array { return ['name', 'email']; } // Or: block specific ones, allow everything else public function strTokenBlacklist(): array { return ['password', 'remember_token', 'api_token']; } }
// config/str-tokens.php — applies to every model 'disable_model_tokens' => ['*password*', '*token*', '*secret*'], // blocks [config:...] lookups the same way 'disable_configs' => ['app.key', 'auth.*', 'mail.*', 'services.*', 'password', '*token*'],
All four accept Str::is() glob patterns (* wildcards),
and are checked against the full token name — so manager:* blocks every field of that relation in
one line.
Configuration
Everything below lives in config/str-tokens.php after publishing it; all keys are optional.
| Key | Default | Purpose |
|---|---|---|
token_split_character |
: |
Separator inside a token name (type:a:b:c) |
token_match_pattern |
/\[([^\s\[\]:]*):([^\[\]]*)\]/x |
The whole token regex — override to use a different bracket style entirely |
can_traverse_relations |
true |
Set false to disable nested-relation resolution globally |
disable_model_tokens |
[] |
Glob patterns blocked on every model |
disable_configs |
see file | Glob patterns blocked for [config:...] |
date.formats |
short, medium, long, time, date, my |
Named formats for [date:name]; add your own key/format pair |
formatters |
trim, uppercase, lowercase, clearHtml, urlLink |
See Formatters |
Use cases
Admin-configurable notification templates. Store the template text (mail subject/body, SMS text) in the DB, let an admin edit it with a list of available tokens, resolve it against the real model when the notification actually sends:
$template = NotificationTemplate::where('key', 'OrderShipped')->first(); $body = StrToken::setText($template->body) ->setEntity($order) ->replace();
Blade.
@php($body = \StrToken::setEntity($article)->setDate($article->created_at) ->setText('[article:title] — published [date:short]') ->replace()) <h3>{!! $body !!}</h3>
Building an SMS/email body with a length cap — combine a custom strToken*() method with its own
parameter instead of relying on formatter chaining (see Custom tokens).
Multi-tenant/CMS content blocks where several unrelated records feed one string — use setEntities()
with descriptive keys instead of forcing everything onto one model's relations.
Behavior notes
- An unresolved token becomes
''by default. Call->doNotClearEmptyTokens()to leave genuinely unmatched token types as literal text instead — this only applies to a token type that doesn't match any known entity/var/date/configat all;[var:missingKey]still resolves to''either way, since thevarnamespace always produces a value (empty if the key is missing). - A resolved value that isn't a string, number, bool, or
Stringableobject (e.g. a JSON-cast array column read without a formatter) resolves to''rather than the literal textArrayor a fatal error. [date:raw]returns the actualCarboninstance, not a formatted string — useful when you want to pass it on to more Carbon methods before printing it yourself.
Testing
composer test # or vendor/bin/phpunit
Related
- laravel-simple-taxonomy — if a model uses it, its
taxonomy relations are reachable via the
txprefix convention ([article:txCategories:name]); not required otherwise.
License
MIT — see LICENSE.
Support
If this package is useful to you, consider supporting its development:
USDT TRC20 address:
THLgp6DxiAtbNHvgnKV56vk1L38UuUagKf