christian-riesen/base32
A strict RFC 4648 Base32 encoder and decoder for PHP, with support for the extended hex alphabet. Decoding rejects malformed input, and the package includes test vectors and unit tests.
Install and verify in one minute.
composer require christian-riesen/base32
Confirm which line you land on — this matters:
composer show christian-riesen/base32
^2.0 (current, PHP 8.1+, what Composer resolves by default on a modern Laravel app): strict, RFC 4648-conformant decoding that throws on bad input.^1.6 (PHP 7.2–8.0 only): lenient decoder that silently strips unknown characters. Older advice tells you to pin ^1.6 for a historical correctness bug — that guidance is stale. On Laravel 9+ / PHP 8.1+ you want ^2.0.Where to look first: the README usage block and the 2.0 changelog. That's genuinely the whole package. There is no service provider, no config file, no facade, no published assets. Two static classes under the Base32\ namespace: Base32 (standard alphabet) and Base32Hex (extended hex). The method surface is encode() / decode() and nothing else.
Smoke test it before wiring anything up:
use Base32\Base32;
Base32::encode('fooba'); // 'MZXW6YTB'
Base32::decode('MZXW6YTB'); // 'fooba'
First real use case in a Laravel app: TOTP secret generation. Base32 is the encoding the otpauth:// provisioning URI and every authenticator app require. This library solves the codec — not the OTP math:
// app/Services/Auth/TwoFactorSecret.php
namespace App\Services\Auth;
use Base32\Base32;
final class TwoFactorSecret
{
public static function generate(int $bytes = 20): string
{
return Base32::encode(random_bytes($bytes)); // 32 chars, no padding for 20 bytes
}
}
Twenty random bytes encodes to exactly 32 Base32 characters with no padding — the shape Google Authenticator and friends expect. Then:
otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30
That's your first shippable increment. Note the boundary: this package does not verify codes. It has no HMAC, no time-step, no hash_equals(). If your requirement is "verify a 6-digit code," adopt pragmarx/google2fa or spomky-labs/otphp (both of which already vendor this library) rather than building OTP yourself on top of an encoder.
Wrap the static calls behind a bound interface. Static classes are untestable-by-substitution and lock you to the concrete implementation. One interface, one adapter, one binding — ten minutes of work that saves you later:
// app/Services/Encoding/Base32Codec.php
namespace App\Services\Encoding;
interface Base32Codec
{
public function encode(string $binary): string;
public function decode(string $encoded): string;
}
// app/Services/Encoding/RfcBase32Codec.php
final class RfcBase32Codec implements Base32Codec
{
public function encode(string $binary): string
{
return \Base32\Base32::encode($binary);
}
public function decode(string $encoded): string
{
return \Base32\Base32::decode($encoded);
}
}
// app/Providers/AppServiceProvider.php (boot or register)
$this->app->bind(
\App\Services\Encoding\Base32Codec::class,
\App\Services\Encoding\RfcBase32Codec::class,
);
Now two-factor jobs, controllers and tests all inject Base32Codec and you can fake it in a unit test without touching global state. This is the single highest-value pattern here.
Normalize at the edge, decode in the core. Because decode() is strict, push normalization to a FormRequest or a dedicated service method, so the codec only ever sees canonical input:
public function normalizeSecret(string $input): string
{
$secret = strtoupper(preg_replace('/[\s-]+/', '', $input));
// Reject early with a clean validation message instead of a thrown exception deeper in.
if (strlen($secret) % 8 !== 0 && ! in_array(strlen($secret) % 8, [2, 4, 5, 7], true)) {
throw ValidationException::withMessages(['secret' => 'Invalid Base32 secret.']);
}
return $secret;
}
Encode on the way out, decode only on trusted boundaries. encode() is conformant and round-trip safe — anything it emits, decode() accepts. So the safe shape is: encode generated values (secrets, provisioning URIs, export tokens) freely; decode only inputs you have normalized, and always inside a try/catch.
A validation rule for user-supplied secrets — the package ships no isBase32() helper, so build one:
namespace App\Rules;
use Base32\Base32;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
final class ValidBase32 implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$normalized = strtoupper(preg_replace('/[\s-]+/', '', (string) $value));
try {
Base32::decode($normalized);
} catch (\InvalidArgumentException) {
$fail('The :attribute is not a valid Base32 value.');
}
}
}
Persist secrets in a cast or encrypted attribute, not as raw strings in controllers. A TwoFactorSecret cast (or 'secret' => 'encrypted' plus your codec) keeps the encode/decode boundary in one place and keeps secrets out of logs and toArray() output:
protected function casts(): array
{
return ['two_factor_secret' => 'encrypted'];
}
Queue jobs and exports. Anywhere you need a URL-safe, case-insensitive-ish identifier (backup-code bundles, CSV export row keys, partner file interchange that mandates Base32), inject the codec. Because the output alphabet is A–Z2–7, there's no +// and no URL escaping — that's the practical reason to reach for Base32 over Base64 in a filename or query-string context.
Testing. Use the RFC test vectors ("", f, fo, foo, foob, fooba, foobar) as a one-time Pest/PHPUnit dataset. Then fake the interface in feature tests so your auth flow tests don't depend on the library at all.
2.0 strictness is the big one. Since 2.0, decode() throws \InvalidArgumentException when the input is not uppercase, has a length not a multiple of 8, contains characters outside the alphabet, has misplaced = padding, or carries non-zero trailing bits (non-canonical encoding). If you copy code from a pre-2024 tutorial that assumed silent sanitization, it will now blow up in production. Always strtoupper() + strip whitespace first.
Uppercase normalization is mandatory, not cosmetic. TOTP secrets are conventionally displayed grouped and lowercase (jbsw y3dp ehpk 3pxp). The RFC alphabet is uppercase. strtoupper(preg_replace('/\s+/', '', $input)) before decode is the documented migration path.
Base32 and Base32Hex are silently incompatible. They encode the same bytes to different strings and produce garbage if you decode one with the other — no exception, just wrong output. Decide once per integration and never mix. For TOTP/authenticator work, you want Base32, not the hex variant. The hex variant exists for DNSSEC-adjacent consumers; if you're not sure you need it, you don't.
Padding behaviour. The decoder requires correct padding (length multiple of 8), so MZXW6YTB works but a truncated string from a system that stripped = will not. Know your source system: some APIs strip Base32 padding on output. If yours does, pad back up to a multiple of 8 with = before decoding, or you'll throw on otherwise-valid data.
No timing-safe comparison is exposed. Verification logic must decode and then compare with hash_equals(). Never use === on Base32 secrets or codes in the auth path. This is a design constraint of the package, not a missing feature to work around locally.
No isBase32(). Encoding only. Validation is your job — see the rule above. Trivial to write, but scope it as work rather than assuming it ships.
No framework integration, so no .env/config quirks — and no facade either. Don't expect Base32::encode() to be swappable via config('...'). The interface binding is what gives you that.
Exceptions are SPL, not package-specific. You catch \InvalidArgumentException, not a Base32Exception, despite what older assessments say. Registering a global handler for \InvalidArgumentException to render a 422 would be a mistake — scope the catch to your codec boundary.
Single-maintainer supply chain. MIT, zero transitive dependencies, unit-tested against RFC vectors — low legal burden. But it's one maintainer and no documented security-response SLA. Mitigations: pin the major (^2.0), and keep it confined to your RfcBase32Codec class so a swap is a one-file change.
PHP 8.1+ floor. On 2.0 you can't run this on PHP 7.4/8.0. If you're maintaining an older Laravel app, ^1.6 is the branch — and at minimum ^1.6, since earlier 1.x lines had a correctness bug. Two different codebases may need two different pins; check composer.lock per service.
Extension point. There isn't one — the classes are effectively final in usage and expose only encode/decode. All the extensibility you need lives in your wrapper: custom validators, normalization, exception mapping, and logging of rejected inputs. Put it there, not in a fork.
How can I help you explore Laravel packages today?