diff --git a/_utils/templates/generate-compose-json.php b/_utils/templates/generate-compose-json.php
--- a/_utils/templates/generate-compose-json.php
+++ b/_utils/templates/generate-compose-json.php
@@ -140,7 +140,11 @@
                 "name" => "Keruald contributors",
             ],
         ],
+        "provide" => [
+            "psr/simple-cache-implementation" => "1.0|2.0|3.0",
+        ],
         "require" => [
+            "psr/simple-cache" => "^1.0|^2.0|^3.0",
             "ext-intl" => "*",
         ],
         "require-dev" => [
@@ -153,6 +157,10 @@
             "symfony/yaml" => "^6.0.3",
             "squizlabs/php_codesniffer" => "^3.6",
         ],
+        "suggest" => [
+            "ext-memcached" => "*",
+            "ext-redis" => "*",
+        ],
         "replace" => getReplace($metadata["packages"]),
         "autoload" => getAutoload($metadata["packages_namespaces"]),
         "scripts" => [
diff --git a/cache/LICENSE b/cache/LICENSE
new file mode 100644
--- /dev/null
+++ b/cache/LICENSE
@@ -0,0 +1,23 @@
+Copyright (c) 2010, 2023 Sébastien Santoro aka Dereckson
+Some rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+  * Redistributions of source code must retain the above copyright notice, this
+    list of conditions and the following disclaimer.
+
+  * Redistributions in binary form must reproduce the above copyright notice,
+    this list of conditions and the following disclaimer in the documentation
+    and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED.  IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/cache/README.md b/cache/README.md
new file mode 100644
--- /dev/null
+++ b/cache/README.md
@@ -0,0 +1,42 @@
+# keruald/cache
+
+This library offers a simple layer of abstraction
+for cache operations, with concrete implementations.
+
+This cache implementation is compatible with PSR-16.
+
+This cache implementation is NOT compatible with PSR-6.
+
+## Configuration
+
+To get a cache instance, you need to pass configuration as an array.
+
+The properties and values depend on the engine you want to use.
+
+### Memcached
+
+| Key           | Value                          | Default     |
+|---------------|--------------------------------|:------------|
+| engine        | MemcachedCache class reference |             |
+| server        | The memcached hostname         | "localhost" |
+| port          | The memcached port             | 11211       |
+| sasl_username | The SASL username              |             |
+| sasl_password | The SASL password              | ""          |
+
+### Redis
+
+| Key      | Value                          | Default     |
+|----------|--------------------------------|:------------|
+| engine   | MemcachedCache class reference |             |
+| server   | The memcached hostname         | "localhost" |
+| port     | The memcached port             | 6379        |
+| database | The redis database number      | 0           |
+
+### Void
+
+This cache allows unit tests or to offer a default cache,
+when no other configuration is offered.
+
+| Key        | Value                     |
+|------------|---------------------------|
+| engine     | VoidCache class reference |
diff --git a/cache/composer.json b/cache/composer.json
new file mode 100644
--- /dev/null
+++ b/cache/composer.json
@@ -0,0 +1,29 @@
+{
+    "name": "keruald/cache",
+    "description": "Abstraction layer for cache. Compatible PSR-16.",
+    "keywords": [
+        "keruald",
+        "cache"
+    ],
+    "minimum-stability": "stable",
+    "license": "BSD-2-Clause",
+    "authors": [
+        {
+            "name": "Sébastien Santoro",
+            "email": "dereckson@espace-win.org"
+        }
+    ],
+    "provide": {
+        "psr/simple-cache-implementation": "1.0|2.0|3.0"
+    },
+    "require": {
+        "keruald/omnitools": "^0.11"
+    },
+    "require-dev": {
+        "phpunit/phpunit": "^10.2"
+    },
+    "suggest": {
+        "ext-memcached": "*",
+        "ext-redis": "*"
+    }
+}
diff --git a/cache/src/Cache.php b/cache/src/Cache.php
new file mode 100644
--- /dev/null
+++ b/cache/src/Cache.php
@@ -0,0 +1,49 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\Cache;
+
+use Keruald\OmniTools\Collections\MultipleOperation;
+use Psr\SimpleCache\CacheInterface;
+
+use DateInterval;
+
+abstract class Cache implements CacheInterface {
+
+    ///
+    /// Loader
+    ///
+
+    public abstract static function load (array $config) : Cache;
+
+    ///
+    /// Default implementation for CacheInterface -Multiple methods
+    ///
+
+    public function getMultiple (
+        iterable $keys,
+        mixed    $default = null
+    ) : iterable {
+        foreach ($keys as $key) {
+            yield $key => $this->get($key, $default);
+        }
+    }
+
+    public function setMultiple (
+        iterable $values,
+        DateInterval|int|null $ttl = null
+    ) : bool {
+        return MultipleOperation::do(
+            $values,
+            fn($key, $value) => $this->set($key, $value, $ttl)
+        );
+    }
+
+    public function deleteMultiple (iterable $keys) : bool {
+        return MultipleOperation::do(
+            $keys,
+            fn($key) => $this->delete($key)
+        );
+    }
+
+}
diff --git a/cache/src/CacheFactory.php b/cache/src/CacheFactory.php
new file mode 100644
--- /dev/null
+++ b/cache/src/CacheFactory.php
@@ -0,0 +1,44 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\Cache;
+
+use Keruald\Cache\Engines\CacheVoid;
+use Keruald\Cache\Exceptions\CacheException;
+
+/**
+ * Cache caller
+ */
+class CacheFactory {
+
+    const DEFAULT_ENGINE = CacheVoid::class;
+
+    /**
+     * Loads the cache instance, building it according a configuration array.
+     *
+     * The correct cache instance to initialize will be determined from the
+     * 'engine' key. It should match the name of a Cache class.
+     *
+     * This method will create an instance of the specified object,
+     * calling the load static method from this object class.
+     *
+     * Example:
+     * <code>
+     * $config['engine'] = CacheQuux::class;
+     * $cache = Cache::load($config); //Cache:load() will call CacheQuux:load();
+     * </code>
+     *
+     * @return Cache the cache instance
+     * @throws CacheException
+     */
+    static function load (array $config) : Cache {
+        $engine = $config["engine"] ?? self::DEFAULT_ENGINE;
+
+        if (!class_exists($engine)) {
+            throw new CacheException("Can't initialize $engine cache engine. The class can't be found.");
+        }
+
+        return call_user_func([$engine, 'load'], $config);
+    }
+
+}
diff --git a/cache/src/Engines/CacheMemcached.php b/cache/src/Engines/CacheMemcached.php
new file mode 100644
--- /dev/null
+++ b/cache/src/Engines/CacheMemcached.php
@@ -0,0 +1,161 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\Cache\Engines;
+
+use Keruald\Cache\Cache;
+use Keruald\Cache\Exceptions\CacheException;
+use Keruald\Cache\Features\WithPrefix;
+use Keruald\OmniTools\Collections\HashMap;
+use Keruald\OmniTools\Collections\Vector;
+
+use Memcached;
+
+/**
+ * Memcached cache
+ *
+ * /!\ This class uses the Memcached extension AND NOT Memcache.
+ *
+ * References:
+ *
+ * @link https://www.php.net/manual/en/book.memcached.php
+ * @link https://memcached.org
+ */
+class CacheMemcached extends Cache {
+
+    use WithPrefix;
+
+    ///
+    /// Constants - default value
+    ///
+
+    const DEFAULT_SERVER = "localhost";
+
+    const DEFAULT_PORT = 11211;
+
+    ///
+    /// Properties
+    ///
+
+    private Memcached $memcached;
+
+    ///
+    /// Constructors
+    ///
+
+    public function __construct (Memcached $memcached) {
+        $this->memcached = $memcached;
+    }
+
+    public static function load (array $config) : self {
+        //Checks extension is okay
+        if (!extension_loaded('memcached')) {
+            if (extension_loaded('memcache')) {
+                throw new CacheException("Can't initialize Memcached cache engine: PHP extension memcached not loaded. This class uses the Memcached extension AND NOT the Memcache extension (this one is loaded).</strong>");
+            } else {
+                throw new CacheException("Can't initialize Memcached cache engine: PHP extension memcached not loaded.");
+            }
+        }
+
+        $memcached = new Memcached;
+        $memcached->addServer(
+            $config["server"] ?? self::DEFAULT_SERVER,
+            $config["port"] ?? self::DEFAULT_PORT,
+        );
+
+        // SASL authentication
+        if (array_key_exists("sasl_username", $config)) {
+            $memcached->setOption(Memcached::OPT_BINARY_PROTOCOL, true);
+            $memcached->setSaslAuthData(
+                $config["sasl_username"],
+                $config["sasl_password"] ?? "",
+            );
+        }
+
+        return new self($memcached);
+    }
+
+    ///
+    /// Cache operations
+    ///
+
+    /**
+     * Gets the specified key's data
+     */
+    function get (string $key, mixed $default = null) : mixed {
+        $key = $this->getUnsafePrefix() . $key;
+
+        $result = $this->memcached->get($key);
+
+        return match ($result) {
+            false => $default,
+            default => unserialize($result),
+        };
+    }
+
+    /**
+     * Sets the specified data at the specified key
+     */
+    function set (
+        string $key,
+        mixed $value,
+        null|int|\DateInterval $ttl = null
+    ) : bool {
+        $key = $this->getUnsafePrefix() . $key;
+
+        return $this->memcached->set($key, serialize($value));
+    }
+
+    /**
+     * Deletes the specified key's data
+     *
+     * @param string $key the key to delete
+     */
+    function delete (string $key) : bool {
+        $key = $this->getUnsafePrefix() . $key;
+
+        return $this->memcached->delete($key);
+    }
+
+    public function clear () : bool {
+        $keys = $this->memcached->getAllKeys();
+
+        if ($keys === false) {
+            return false;
+        }
+
+        if ($this->hasPrefix()) {
+            // Restrict to our keys, as we don't use Memcached::OPT_PREFIX_KEY
+            $prefix = $this->getUnsafePrefix();
+            $keys = Vector::from($keys)
+                          ->filter(fn($key) => str_starts_with($key, $prefix))
+                          ->toArray();
+        }
+
+        $result = $this->memcached->deleteMulti($keys);
+        return self::areAllOperationsSuccessful($result);
+    }
+
+    public function has (string $key) : bool {
+        $key = $this->getUnsafePrefix() . $key;
+
+        $this->memcached->get($key);
+
+        return match ($this->memcached->getResultCode()) {
+            Memcached::RES_NOTFOUND => false,
+            default => true,
+        };
+    }
+
+    ///
+    /// Helper methods
+    ///
+
+    private static function areAllOperationsSuccessful (array $result) : bool {
+        return HashMap::from($result)
+            ->all(function ($key, $value) {
+                return $value === true; // can be true or Memcached::RES_*
+            });
+    }
+
+}
diff --git a/cache/src/Engines/CacheRedis.php b/cache/src/Engines/CacheRedis.php
new file mode 100644
--- /dev/null
+++ b/cache/src/Engines/CacheRedis.php
@@ -0,0 +1,163 @@
+<?php
+
+namespace Keruald\Cache\Engines;
+
+use Keruald\Cache\Cache;
+use Keruald\Cache\Exceptions\CacheException;
+
+use DateInterval;
+use DateTimeImmutable;
+
+use Redis;
+use RedisException;
+
+class CacheRedis extends Cache {
+
+    ///
+    /// Constants - default value
+    ///
+
+    const DEFAULT_SERVER = "localhost";
+
+    const DEFAULT_PORT = 6379;
+
+    ///
+    /// Properties
+    ///
+
+    private Redis $redis;
+
+    ///
+    /// Constructors
+    ///
+
+    public function __construct (Redis $client) {
+        $this->redis = $client;
+    }
+
+    public static function load (array $config) : Cache {
+        //Checks extension is okay
+        if (!extension_loaded("redis")) {
+            throw new CacheException("Can't initialize Redis cache engine: PHP extension redis not loaded.");
+        }
+
+        $client = new Redis();
+        try {
+            $client->connect(
+                $config["server"] ?? self::DEFAULT_SERVER,
+                $config["port"] ?? self::DEFAULT_PORT,
+            );
+
+            if (array_key_exists("database", $config)) {
+                $client->select($config["database"]);
+            }
+
+        } catch (RedisException $ex) {
+            throw new CacheException(
+                "Can't initialize Redis cache engine: Can't connect to Redis server",
+                0,
+                $ex
+            );
+        }
+
+        return new self($client);
+    }
+
+    ///
+    /// Cache operations
+    ///
+
+    public function get (string $key, mixed $default = null) : mixed {
+        try {
+            $value = $this->redis->get($key);
+        } catch (RedisException $ex) {
+            throw new CacheException("Can't get item", 0, $ex);
+        }
+
+        return match ($value) {
+            false => $default,
+            default => unserialize($value),
+        };
+    }
+
+    function set (
+        string $key,
+        mixed $value,
+        null|int|DateInterval $ttl = null
+    ) : bool {
+        try {
+            if ($ttl === null) {
+                $this->redis->set($key, serialize($value));
+            } else {
+                $this->redis->setex($key, self::parse_interval($ttl), $value);
+            }
+        } catch (RedisException $ex) {
+            throw new CacheException("Can't set item", 0, $ex);
+        }
+
+        return true;
+    }
+
+    public function delete (string $key) : bool {
+        try {
+            $countDeleted = $this->redis->del($key);
+        } catch (RedisException $e) {
+            throw new CacheException("Can't delete item", 0, $ex);
+        }
+
+        return $countDeleted === 1;
+    }
+
+    public function clear () : bool {
+        try {
+            $this->redis->flushDB();
+        } catch (RedisException $e) {
+            throw new CacheException("Can't clear cache", 0, $ex);
+        }
+
+        return true;
+    }
+
+    public function has (string $key) : bool {
+        try {
+            $count = $this->redis->exists($key);
+        } catch (RedisException $e) {
+            throw new CacheException("Can't check item", 0, $ex);
+        }
+
+        return $count === 1;
+    }
+
+    ///
+    /// Overrides for multiple operations
+    ///
+
+    public function deleteMultiple (iterable $keys) : bool {
+        $keys = [...$keys];
+        $expectedCount = count($keys);
+
+        try {
+            $countDeleted = $this->redis->del($keys);
+        } catch (RedisException $e) {
+            throw new CacheException("Can't delete items", 0, $ex);
+        }
+
+        return $countDeleted === $expectedCount;
+    }
+
+    ///
+    /// Helper methods
+    ///
+
+    private static function parse_interval (DateInterval|int $ttl) : int {
+        if (is_integer($ttl)) {
+            return $ttl;
+        }
+
+        $start = new DateTimeImmutable;
+        $end = $start->add($ttl);
+
+        return $end->getTimestamp() - $start->getTimestamp();
+    }
+
+}
diff --git a/cache/src/Engines/CacheVoid.php b/cache/src/Engines/CacheVoid.php
new file mode 100644
--- /dev/null
+++ b/cache/src/Engines/CacheVoid.php
@@ -0,0 +1,69 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\Cache\Engines;
+
+use Keruald\Cache\Cache;
+
+use DateInterval;
+
+/**
+ * "blackhole" void cache
+ *
+  * This class doesn't cache information, it's void wrapper
+ *  get will always return null
+ *  set and delete do nothing
+ *
+ * It will be used by default if no cache is specified.
+ */
+class CacheVoid extends Cache {
+
+    static function load ($config) : self {
+        return new static;
+    }
+
+    function get (string $key, mixed $default = null) : mixed {
+       return $default;
+    }
+
+    function set (
+        string $key,
+        mixed $value,
+        null|int|DateInterval $ttl = null,
+    ) : bool {
+        return true;
+    }
+
+    function delete (string $key) : bool {
+        return true;
+    }
+
+    public function clear () : bool {
+        return true;
+    }
+
+    public function has (string $key) : bool {
+        return false;
+    }
+
+    public function getMultiple (
+        iterable $keys,
+        mixed    $default = null
+    ) : iterable {
+        foreach ($keys as $key) {
+            yield $key => $default;
+        }
+    }
+
+    public function setMultiple (
+        iterable $values,
+        null|int|DateInterval $ttl = null,
+    ) : bool {
+        return true;
+    }
+
+    public function deleteMultiple (iterable $keys) : bool {
+        return true;
+    }
+
+}
diff --git a/cache/src/Exceptions/CacheException.php b/cache/src/Exceptions/CacheException.php
new file mode 100644
--- /dev/null
+++ b/cache/src/Exceptions/CacheException.php
@@ -0,0 +1,11 @@
+<?php
+
+namespace Keruald\Cache\Exceptions;
+
+use Psr\SimpleCache\CacheException as CacheExceptionInterface;
+
+use RuntimeException;
+
+class CacheException extends RuntimeException implements CacheExceptionInterface {
+
+}
diff --git a/cache/src/Features/WithPrefix.php b/cache/src/Features/WithPrefix.php
new file mode 100644
--- /dev/null
+++ b/cache/src/Features/WithPrefix.php
@@ -0,0 +1,58 @@
+<?php
+declare(strict_types=1);
+
+namespace Keruald\Cache\Features;
+
+use InvalidArgumentException;
+
+trait WithPrefix {
+
+    ///
+    /// Properties
+    ///
+
+    private string $prefix = "";
+
+    ///
+    /// Getters and setters
+    ///
+
+    public function getPrefix () : string {
+        if ($this->prefix === "") {
+            throw new InvalidArgumentException("This cache doesn't use prefix");
+        }
+
+        return $this->prefix;
+    }
+
+    protected function getUnsafePrefix () : string {
+        return $this->prefix;
+    }
+
+    public function hasPrefix () : bool {
+        return $this->prefix !== "";
+    }
+
+    /**
+     * Allows to share ab instance with several applications
+     * by prefixing the keys.
+     *
+     * @throws InvalidArgumentException
+     */
+    public function setPrefix (string $prefix) : self {
+        if ($prefix === "") {
+            throw new InvalidArgumentException("Prefix must be a non-empty string");
+        }
+
+        $this->prefix = $prefix;
+
+        return $this;
+    }
+
+    public function clearPrefix () : self {
+        $this->prefix = "";
+
+        return $this;
+    }
+
+}
diff --git a/cache/tests/CacheDummy.php b/cache/tests/CacheDummy.php
new file mode 100644
--- /dev/null
+++ b/cache/tests/CacheDummy.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Keruald\Cache\Tests;
+
+use Keruald\Cache\Engines\CacheVoid;
+
+class CacheDummy extends CacheVoid {
+
+}
diff --git a/cache/tests/CacheFactoryTest.php b/cache/tests/CacheFactoryTest.php
new file mode 100644
--- /dev/null
+++ b/cache/tests/CacheFactoryTest.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Keruald\Cache\Tests;
+
+use Keruald\Cache\CacheFactory;
+use Keruald\Cache\Engines\CacheVoid;
+
+use PHPUnit\Framework\TestCase;
+use Psr\SimpleCache\CacheException;
+
+class CacheFactoryTest extends TestCase {
+
+    public function testLoad () {
+        $config = [
+            "engine" => CacheDummy::class,
+        ];
+        $cache = CacheFactory::load($config);
+
+        $this->assertInstanceOf(CacheDummy::class, $cache);
+    }
+
+    public function testLoadDefaultsToVoid () {
+        $cache = CacheFactory::load([]);
+
+        $this->assertInstanceOf(CacheVoid::class, $cache);
+    }
+
+    public function testLoadWithNonExistentClass () {
+        $config = [
+            "engine" => "Acme\\Nonexistent",
+        ];
+
+        $this->expectException(CacheException::class);
+        CacheFactory::load($config);
+    }
+
+}
diff --git a/cache/tests/Engines/CacheMemcachedTest.php b/cache/tests/Engines/CacheMemcachedTest.php
new file mode 100644
--- /dev/null
+++ b/cache/tests/Engines/CacheMemcachedTest.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace Keruald\Cache\Tests\Engines;
+
+use Keruald\Cache\Engines\CacheMemcached;
+use Keruald\OmniTools\Collections\HashMap;
+
+use Keruald\OmniTools\Network\SocketAddress;
+use PHPUnit\Framework\TestCase;
+
+use Memcached;
+
+class CacheMemcachedTest extends TestCase {
+
+    private CacheMemcached $cache;
+
+    protected function setUp () : void {
+
+        if (!extension_loaded("memcached")) {
+            $this->markTestSkipped("Memcached extension is required to test.");
+        }
+
+        if (!SocketAddress::from("127.0.0.1", 11211)->isOpen()) {
+            $this->markTestSkipped("Memcached server can't be reached.");
+        }
+
+        $memcached = new Memcached();
+        $memcached->addServer("127.0.0.1", 11211);
+
+        $this->cache = new CacheMemcached($memcached);
+    }
+
+    public function testSet () {
+        $result = $this->cache->set("foo", "bar");
+
+        $this->assertTrue($result);
+    }
+
+    public function testGet () {
+        $this->cache->set("foo", "bar");
+
+        $this->assertEquals("bar", $this->cache->get("foo"));
+    }
+
+    public function testHas () {
+        $result = $this->cache->set("foo", "bar");
+
+        $this->assertTrue($this->cache->has("foo"));
+    }
+
+    public function testDelete () {
+        $this->cache->set("foo", "bar");
+        $result = $this->cache->delete("foo");
+
+        $this->assertTrue($result);
+    }
+
+    public function testClear () {
+        $result = $this->cache->clear();
+
+        $this->assertTrue($result);
+    }
+
+    public function testGetMultiple () {
+        $expected = [
+            "foo" => "bar",
+            "bar" => "baz",
+        ];
+
+        $this->cache->set("foo", "bar");
+        $this->cache->set("bar", "baz");
+
+        $results = $this->cache->getMultiple(["foo", "bar"]);
+        $results = HashMap::from($results)->toArray();
+
+        $this->assertEquals($expected, $results);
+    }
+
+    public function testDeleteMultiple () {
+        $this->cache->set("foo", "bar");
+        $this->cache->set("bar", "baz");
+
+        $result = $this->cache->deleteMultiple(["foo", "bar"]);
+
+        $this->assertTrue($result);
+    }
+
+    public function testSetMultiple () {
+        $result = $this->cache->setMultiple([
+            "foo" => "bar",
+            "bar" => "baz",
+        ]);
+
+        $this->assertTrue($result);
+    }
+
+    public function testLoad () {
+        $cache = CacheMemcached::load([]);
+
+        $this->assertInstanceOf(CacheMemcached::class, $cache);
+    }
+}
diff --git a/cache/tests/Engines/CacheRedisTest.php b/cache/tests/Engines/CacheRedisTest.php
new file mode 100644
--- /dev/null
+++ b/cache/tests/Engines/CacheRedisTest.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace Keruald\Cache\Tests\Engines;
+
+use Keruald\Cache\Engines\CacheRedis;
+use Keruald\OmniTools\Collections\HashMap;
+
+use Keruald\OmniTools\Network\SocketAddress;
+use PHPUnit\Framework\TestCase;
+
+use Redis;
+
+class CacheRedisTest extends TestCase {
+
+    private CacheRedis $cache;
+
+    protected function setUp () : void {
+
+        if (!extension_loaded("redis")) {
+            $this->markTestSkipped("Redis extension is required to test.");
+        }
+
+        if (!SocketAddress::from("127.0.0.1", 6379)->isOpen()) {
+            $this->markTestSkipped("Redis server can't be reached.");
+        }
+
+        $Redis = new Redis();
+        $Redis->connect("127.0.0.1", 6379);
+
+        $this->cache = new CacheRedis($Redis);
+    }
+
+    public function testSet () {
+        $result = $this->cache->set("foo", "bar");
+
+        $this->assertTrue($result);
+    }
+
+    public function testGet () {
+        $this->cache->set("foo", "bar");
+
+        $this->assertEquals("bar", $this->cache->get("foo"));
+    }
+
+    public function testHas () {
+        $result = $this->cache->set("foo", "bar");
+
+        $this->assertTrue($this->cache->has("foo"));
+    }
+
+    public function testDelete () {
+        $this->cache->set("foo", "bar");
+        $result = $this->cache->delete("foo");
+
+        $this->assertTrue($result);
+    }
+
+    public function testClear () {
+        $result = $this->cache->clear();
+
+        $this->assertTrue($result);
+    }
+
+    public function testGetMultiple () {
+        $expected = [
+            "foo" => "bar",
+            "bar" => "baz",
+        ];
+
+        $this->cache->set("foo", "bar");
+        $this->cache->set("bar", "baz");
+
+        $results = $this->cache->getMultiple(["foo", "bar"]);
+        $results = HashMap::from($results)->toArray();
+
+        $this->assertEquals($expected, $results);
+    }
+
+    public function testDeleteMultiple () {
+        $this->cache->set("foo", "bar");
+        $this->cache->set("bar", "baz");
+
+        $result = $this->cache->deleteMultiple(["foo", "bar"]);
+
+        $this->assertTrue($result);
+    }
+
+    public function testSetMultiple () {
+        $result = $this->cache->setMultiple([
+            "foo" => "bar",
+            "bar" => "baz",
+        ]);
+
+        $this->assertTrue($result);
+    }
+
+    public function testLoad () {
+        $cache = CacheRedis::load([]);
+
+        $this->assertInstanceOf(CacheRedis::class, $cache);
+    }
+}
diff --git a/cache/tests/Engines/CacheVoidTest.php b/cache/tests/Engines/CacheVoidTest.php
new file mode 100644
--- /dev/null
+++ b/cache/tests/Engines/CacheVoidTest.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace Keruald\Cache\Tests\Engines;
+
+use Keruald\Cache\Engines\CacheVoid;
+use Keruald\OmniTools\Collections\HashMap;
+
+use PHPUnit\Framework\TestCase;
+
+class CacheVoidTest extends TestCase {
+
+    private CacheVoid $cache;
+
+    protected function setUp () : void {
+        $this->cache = new CacheVoid;
+    }
+
+    public function testSet () {
+        $result = $this->cache->set("foo", "bar");
+
+        $this->assertTrue($result);
+    }
+
+    public function testDelete () {
+        $result = $this->cache->delete("foo");
+
+        $this->assertTrue($result);
+    }
+
+    public function testClear () {
+        $result = $this->cache->clear();
+
+        $this->assertTrue($result);
+    }
+
+    public function testGetMultiple () {
+        $expected = [
+            "foo" => null,
+            "bar" => null,
+        ];
+
+        $results = $this->cache->getMultiple(["foo", "bar"]);
+        $results = HashMap::from($results)->toArray();
+
+        $this->assertEquals($expected, $results);
+    }
+
+    public function testDeleteMultiple () {
+        $result = $this->cache->deleteMultiple(["foo", "bar"]);
+
+        $this->assertTrue($result);
+    }
+
+    public function testGet () {
+        $this->assertNull($this->cache->get("foo"));
+    }
+
+    public function testSetMultiple () {
+        $result = $this->cache->setMultiple([
+            "foo" => "bar",
+            "bar" => "baz",
+        ]);
+
+        $this->assertTrue($result);
+    }
+
+    public function testHas () {
+        $result = $this->cache->has("foo");
+
+        $this->assertFalse($result);
+    }
+
+    public function testLoad () {
+        $cache = CacheVoid::load([]);
+
+        $this->assertInstanceOf(CacheVoid::class, $cache);
+    }
+}
diff --git a/composer.json b/composer.json
--- a/composer.json
+++ b/composer.json
@@ -17,7 +17,11 @@
             "name": "Keruald contributors"
         }
     ],
+    "provide": {
+        "psr/simple-cache-implementation": "1.0|2.0|3.0"
+    },
     "require": {
+        "psr/simple-cache": "^1.0|^2.0|^3.0",
         "ext-intl": "*"
     },
     "require-dev": {
@@ -30,7 +34,12 @@
         "symfony/yaml": "^6.0.3",
         "squizlabs/php_codesniffer": "^3.6"
     },
+    "suggest": {
+        "ext-memcached": "*",
+        "ext-redis": "*"
+    },
     "replace": {
+        "keruald/cache": "0.1.0",
         "keruald/commands": "0.0.1",
         "keruald/database": "0.4.0",
         "keruald/omnitools": "0.11.0",
@@ -38,6 +47,8 @@
     },
     "autoload": {
         "psr-4": {
+            "Keruald\\Cache\\": "cache/src/",
+            "Keruald\\Cache\\Tests\\": "cache/tests/",
             "Keruald\\Commands\\": "commands/src/",
             "Keruald\\Commands\\Tests\\": "commands/tests/",
             "Keruald\\Database\\": "database/src/",
diff --git a/metadata.yml b/metadata.yml
--- a/metadata.yml
+++ b/metadata.yml
@@ -22,12 +22,14 @@
 #   - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 
 packages:
+    - cache
     - commands
     - database
     - omnitools
     - report
 
 packages_namespaces:
+    cache: Keruald\Cache
     commands: Keruald\Commands
     database: Keruald\Database
     omnitools: Keruald\OmniTools