Building a Reliable Encryption Utility in PHP Using Libsodium


Encryption is essential when dealing with sensitive data like API keys, personal information, or internal tokens. PHP comes bundled with libsodium — a powerful cryptographic library designed for secure-by-default encryption. To simplify working with it, I chose Halite, a high-level wrapper around libsodium that handles key management, encryption, and decryption using object-oriented interfaces.
Why This Matters
Many developers focus solely on implementing encryption. But in production systems, you also need to answer:
Can I trust the data will decrypt reliably every time?
What happens when a key is missing or invalid?
How can I scale this system across environments like local, cloud, or containerized setups?
Is my encryption system testable without hitting actual key stores?
In this guide, I’ll walk you through how I built an extensible encryption utility using libsodium and Halite that I use in production to safely store sensitive values like deal notes, contact details, and API tokens.
Why Libsodium?
Libsodium is a cryptographic library that’s built with one purpose: get security right, without the guesswork. It offers:
Modern, authenticated encryption (you can’t accidentally decrypt tampered data)
Key generation and memory safety (including secure string wrappers)
Defaults that follow best practices so I don’t have to pick cipher modes or manage IVs
Since PHP 7.2, libsodium is bundled into PHP, so I didn’t need to install any extra extensions.
Generating Keys with Halite
Libsodium provides the raw functions, but Halite simplifies key generation through its factory interface. This uses libsodium’s sodium_crypto_secretbox_keygen()
under the hood.
To centralize key generation in my project, I wrapped this in a dedicated class:
class HaliteKeyGenerator
{
public function generateKey(): EncryptionKey
{
return KeyFactory::generateEncryptionKey();
}
}
This isolates how keys are created. It also makes unit testing easier because I can inject this generator and mock it if needed.
Defining a KeyStore Interface
Keys had to be stored either in the local filesystem (for development) or AWS Secrets Manager (for production). To support both without coupling my logic, I created a simple interface.
Here’s the interface:
interface KeyStore
{
public function getKey(string $keyName): EncryptionKey;
public function saveKey(string $keyName, EncryptionKey $key): void;
}
And a simple implementation for the local filesystem:
class LocalKeyStore implements KeyStore
{
public function getKey(string $keyName): EncryptionKey
{
$file = __DIR__ . \"/keys/{$keyName}.key\";
if (!file_exists($file)) {
throw new KeyNotFoundException(\"Key not found: {$keyName}\");
}
return KeyFactory::loadEncryptionKey($file);
}
public function saveKey(string $keyName, EncryptionKey $key): void
{
$file = __DIR__ . \"/keys/{$keyName}.key\";
KeyFactory::save($key, $file);
}
}
The CryptoService
At the core is the CryptoService class — this is the part of the system that other developers or services will use. It handles encryption and decryption, and delegates key management to the KeyStore. If the key for contact_notes
doesn’t exist, it’s generated automatically and stored for future use.
class CryptoService
{
public function __construct(
protected KeyStore $store,
protected HaliteKeyGenerator $generator
) {}
public function encrypt(string $plaintext, string $keyName): string
{
try {
$key = $this->store->getKey($keyName);
} catch (KeyNotFoundException) {
$key = $this->generator->generateKey();
$this->store->saveKey($keyName, $key);
}
return base64_encode(Crypto::encrypt(new HiddenString($plaintext), $key));
}
public function decrypt(string $cipher, string $keyName): string
{
$key = $this->store->getKey($keyName);
$decoded = base64_decode($cipher, true);
if ($decoded === false) {
throw new DecryptionFailedException(\"Invalid base64 ciphertext.\");
}
return Crypto::decrypt($decoded, $key)->getString();
}
}
In an Ideal situation I would rather recommend you throw an exception if a key does not exist and ensure to create the keys upfront.
Usage
$keyStore = new LocalKeyStore();
$generator = new HaliteKeyGenerator();
$crypto = new CryptoService($keyStore, $generator);
$encrypted = $crypto->encrypt('my-secret-value', 'contact_notes');
$decrypted = $crypto->decrypt($encrypted, 'contact_notes');
Errors Are Explicit
If a key is missing and cannot be generated:
KeyNotFoundException
If the ciphertext is tampered or invalid:
DecryptionFailedException
This makes the service safe by default and predictable in error states.
Caching for Performance
Each KeyStore implementation caches keys internally for the request lifecycle:
// Inside LocalKeyStore:
private array $cache = [];
public function getKey(string $keyName): EncryptionKey
{
if (isset($this->cache[$keyName])) {
return $this->cache[$keyName];
}
// Load and cache...
}
This is especially helpful in high-performance systems or frameworks that handle hundreds of requests per minute.
Summary
This architecture balances security, flexibility, and clarity. It’s easy to plug in different key storage options, easy to test each part in isolation, and resistant to common failure modes in encryption systems.
This service now:
Leverages libsodium (via Halite) for secure, authenticated encryption
Separates concerns across storage, encryption, and key generation
Handles errors clearly and consistently
Can be deployed in both local and cloud environments
When building a PHP backend that needs to store sensitive information, I recommend starting with libsodium. It’s simple, safe, and built into PHP. This pattern has worked well in my own production systems and helps keep things secure without over-complicating the implementation.
If you're curious about the full implementation (including key stores and Halite integration), or you’re building something similar and want feedback, feel free to reach out.
Subscribe to my newsletter
Read articles from Douglas Okolaa directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Douglas Okolaa
Douglas Okolaa
I am Douglas Okolaa, a Software Engineer and Technical Support Engineer based in Nigeria. I design, build, and support scalable, high-performance software solutions for startups and established teams, leveraging my expertise in PHP (Laravel, Symfony), JavaScript (React.js, Vue.js), and Python. With over four years of experience, I am dedicated to delivering efficient, reliable, and well-optimised systems that drive business growth.