not-empty/jwt-manager-php-lib
A lightweight PHP library for managing JSON Web Tokens, offering helpers for encoding, decoding, signing, and verifying JWTs. It simplifies token handling for authentication and secure API communication, making JWT integration easier in PHP projects.
This is a framework-agnostic JWT library, not a Laravel package. There is no service provider, no publishable config, no facade, and no auth guard shipped with it — you wire all of that yourself. Budget half a day for the first integration, most of it spent on the wrapper, not the library.
Step 1 — confirm you can install it at all. Release 8.0.0 is tagged "for php-8.4", which in practice means a >=8.4 constraint until proven otherwise. Check composer require not-empty/jwt-manager-php-lib in a scratch directory against your app's composer.json, and verify local, CI, and production images all run 8.4. If any of them are on 8.2/8.3, this is a non-starter regardless of feature fit.
Step 2 — read the README, not the release notes. The 8.0.0 release note is a single line. The only real documentation is the repo README: find the manager class FQCN, the constructor signature (secret + algorithm), and the exact method names for encode/decode/validate. Confirm before writing anything else whether expiration and signature checks happen inside decode/validate or must be asserted by you.
Step 3 — decide whether you need this at all. For a typical Laravel app, laravel/sanctum or tymon/jwt-auth already solve this with guards, middleware, refresh, and revocation. Reach for this library only when you need a bare JWT (service-to-service tokens, tokens consumed by a non-Laravel service, an existing token format you must stay compatible with) and you want a small dependency rather than a full auth stack.
Your first use case should be the narrowest one: issue a short-lived token from a login/CLI command, and verify it in one middleware. Do not start by replacing your whole auth system.
Wrap it behind your own interface on day one. This is the single most important pattern here. A package with 14 stars and zero dependents should never be called from more than one class in your codebase. Define App\Contracts\TokenIssuer and App\Contracts\TokenVerifier (or one TokenService) and let the library live only inside that implementation. Swapping to firebase/php-jwt later becomes a one-file change.
// app/Services/TokenService.php
final class TokenService
{
public function __construct(
private readonly object $jwt, // the package manager, exact FQCN per README
private readonly int $ttl,
private readonly int $leeway,
) {}
public function issue(string $subject, array $extra = []): string
{
$now = time();
return $this->jwt->encode(array_merge($extra, [
'sub' => $subject,
'iat' => $now,
'nbf' => $now,
'exp' => $now + $this->ttl,
'jti' => (string) Str::uuid(),
]));
}
public function verify(string $token): array
{
$claims = $this->jwt->decode($token); // confirm whether this throws or returns null
if (($claims['exp'] ?? 0) + $this->leeway < time()) {
throw new TokenExpiredException();
}
return $claims;
}
}
Bind it as a singleton in a service provider. Read the secret from config(), never env(), so php artisan config:cache doesn't hand you a null secret in production.
// config/jwt.php
return [
'secret' => env('JWT_SECRET'),
'algo' => env('JWT_ALGO', 'HS256'),
'ttl' => (int) env('JWT_TTL', 900),
'leeway' => (int) env('JWT_LEEWAY', 30),
];
Use Auth::viaRequest rather than hand-rolling middleware. This is the cleanest Laravel-native bridge for a stateless token and keeps auth()->user() and $request->user() working everywhere else in the app:
// AppServiceProvider::boot()
Auth::viaRequest('jwt', function (Request $request) {
$token = $request->bearerToken();
if (! $token) {
return null;
}
try {
$claims = app(TokenService::class)->verify($token);
} catch (Throwable) {
return null;
}
return User::find($claims['sub']);
});
Register it in config/auth.php as ['driver' => 'jwt', 'provider' => 'users']. Note that viaRequest guards are stateless — auth()->attempt() will not work, and you must issue tokens explicitly from a login controller.
Day-to-day workflows this enables:
Authorization: Bearer on an HTTP client call, verify on the receiving Laravel app.exp, plus a refresh token (random string in your DB) exchanged at a dedicated endpoint. The library gives you no refresh mechanism — that's yours.jti in the claims and check a Cache::has("jwt:denied:{$jti}") denylist in the guard closure. Without jti, short tokens are the only revocation strategy you have.keys table and construct a TokenService per request via a scoped binding.Testing. Freeze time with Laravel's $this->travel(20)->minutes() and assert the guard returns null; assert Cache and User::find are not hit for tampered tokens. Write one test that flips a character in the signature and one that re-signs a token with a different secret — those two catch most real-world misconfiguration.
The PHP 8.4 floor is the biggest practical filter. Most Laravel apps in the wild still run 8.2/8.3. Composer will refuse to resolve, and the error will point at the package's require block. Check this before you design anything.
GPL-3.0 is a genuine consideration, not a formality. This library is copyleft, unlike the MIT-licensed firebase/php-jwt and laravel/sanctum. For a closed-source commercial SaaS, get a legal opinion on GPL-3.0 obligations for a Composer dependency before shipping. Many teams will simply not be allowed to use it.
Supply-chain risk is material. 14 stars, zero dependents, sparse release notes, no visible security policy. Pin the exact version ("8.0.0" not "^8.0") until you've watched a few releases, commit composer.lock, and run composer audit in CI. Note that composer audit only reports known advisories — it will not warn you about an abandoned package. Keep the wrapper seam so a fork is viable.
Never let the token choose the algorithm. Algorithm-confusion (alg: none, or swapping RS256 for HS256 using the public key as the HMAC secret) is the classic JWT exploit. Pin the algorithm in your config and confirm the library ignores or rejects a conflicting alg header. If the API accepts an algorithm argument, pass it explicitly on every call. Test {"alg":"none"} against your guard.
Verify that exp is actually enforced. The description advertises "expiration and signature checks", but check the code or write the test. A decode that returns claims with an exp in the past is a silent auth bypass. The verify() snippet above re-checks exp defensively for exactly this reason — cheap insurance.
Clock skew between services. If the issuer and verifier are different hosts, a 30–60 second leeway on nbf prevents flaky 401s during deploys. Don't set leeway on exp large enough to matter for security (60s max).
env() outside config returns null when config is cached. This bites everyone once. Keep the secret in config/jwt.php, and add a boot-time guard that throws if the secret is empty in a non-local environment — a JWT signed with an empty key is worse than no auth.
Secrets must be at least as long as the hash output. An HS256 secret shorter than 32 bytes weakens the signature regardless of what the library permits. Generate with Str::random(64) and rotate via a kid claim plus a two-key overlap window.
Middleware ordering matters. Put the JWT guard ahead of rate limiting on authenticated routes but behind TrimStrings/ConvertEmptyStringsToNull — actually those touch the body, not the header, but do ensure Authorization survives any proxy or load balancer, which commonly strip it. Verify with $request->header('Authorization') in a tinker session against staging.
Don't stuff large claim sets into tokens. They travel in a header on every request; putting roles, permissions, and a full profile in there inflates request size and makes revocation impossible. Keep sub, jti, iat, exp, scope — everything else loads from the DB or cache.
Token size and transport. JWTs routinely exceed 500 bytes; if you move them from the Authorization header into a cookie or query string, watch header-size limits and remember that URLs end up in access logs. Base64url padding has bitten people copying tokens by hand into Postman.
Where to extend: the only sane extension point is your TokenService. Don't subclass the library's manager — you'd couple yourself to a package with one maintainer. Once the seam exists, multi-tenant keys, refresh flows, jti denylisting, and eventual replacement are all just changes inside one file.
How can I help you explore Laravel packages today?