<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <title>Filis on Software</title>
    <link href="https://filis.me/blog" />
    <link type="application/atom+xml" rel="self" href="https://filis.me/posts/feed.atom" />
    <updated>2026-04-01T02:01:06+00:00</updated>
    <id>https://filis.me/posts/feed.atom</id>
    <author>
        <name>Filis Futsarov</name>
    </author>
                <entry>
    <id>https://filis.me/posts/how-to-make-your-tests-keep-you-focused</id>
    <link type="text/html" rel="alternate" href="https://filis.me/posts/how-to-make-your-tests-keep-you-focused" />
    <title>How to make your tests keep you focused?</title>
    <published>2022-06-24T00:00:00+00:00</published>
    <updated>2022-06-24T00:00:00+00:00</updated>
    <author>
        <name></name>
    </author>
    <summary type="html">This talk covers Ports &amp; Adapters architecture, Contracts, and Acceptance Testing. It demonstrates how to create a fast feedback loop that helps you maintain focus while coding....</summary>
    <content type="html"><![CDATA[
        <p>This is a talk that I gave back in 2022.</p>

<p>It covers Hexagonal Architecture, Contracts (!), and Acceptance Tests. And shows how that can be used to create a fast feedback-loop that will help you to keep your focus while you code.</p>

<p>So the answer to <em>How to make your tests keep you focused?</em> is: by having a very fast test suite. Using where you can the previusly proven Fast adapter by your Contract Tests.</p>

<p><a href="https://drive.google.com/file/d/1fovjFv1EoCSUEJzHmY_XkEfTwIJSzYz3/view?usp=sharing">You can access the Slides in PDF here (Google Drive)</a></p>

<h1>Description</h1>

<p>At the beginning of the talk, I will highlight the importance of having a fast test suite, drawing on what psychology tells us about this, especially how different time scales for obtaining feedback on something we have done affect our brain, with a short feedback-loop being ideal for maintaining momentum and a long feedback-loop being counterproductive to our focus.</p>

<p>The talk will focus on what bad practices we should avoid, and then on how to obtain a healthy test suite, with special emphasis on acceptance tests. To do this, I will introduce the <em>Ports and Adapters</em> architecture and show how to take advantage of it to get the most out of our test suite.</p>

<h1>Proposal</h1>

<p>We've all seen it: when it comes to software design, everything seems to focus on the production code. Names that reveal intent, cohesive classes, loose coupling... but little is said about applying best practices and principles to the other big part of the project: <strong>our test suite</strong>.</p>

<p>For some reason, when it comes to testing, we seem to relax all those good practices, principles, and the continuous pursuit of optimal performance, which over time leads to low-quality tests (a slow feedback-loop, flakiness, and fragility).</p>

<p>Unlike production code, no Product Owner will complain that the tests are slow, nor will an alert pop up in Slack to tell us that our tests are slower than they should be.</p>

<p>It is the developers themselves who are most affected by a poorly maintained test suite, the main cause being a slow feedback-loop, which worsens our focus on what is important, thus greatly affecting our overall Developer Experience as it is such an important part of our daily work.</p>

<p>For all these reasons, we can say that having a healthy test suite is vitally important for any project. Therefore, in this talk, I want to show:</p>

<ul>
<li>The importance of having a fast test suite from a psychological point of view.</li>
<li>Ports and Adapters architecture and its out-of-the-box benefits for testing.</li>
<li>Differences between Integrated Acceptance Testing and Decoupled Acceptance Testing.</li>
<li>Contract Testing/Adapter Tests and how they can help us with our Acceptance Tests.</li>
</ul>

<h1>Take aways</h1>

<ul>
<li>Contract Tests are the supporting mechanism that allows us to run tests in a more efficient way, at least at scale, and it does require actual work.</li>
<li>Care about your test suite as you do for the production code.</li>
<li>Don’t let your Software turn into Hardware (that’s a nice metaphor, isn't it? It came to me while I was driving to this talk!).</li>
</ul>

    ]]></content>
</entry>
            <entry>
    <id>https://filis.me/posts/how-to-decrypt-a-luks-setup-with-a-pendrive</id>
    <link type="text/html" rel="alternate" href="https://filis.me/posts/how-to-decrypt-a-luks-setup-with-a-pendrive" />
    <title>How to automatically decrypt a LUKS LVM setup on boot with a USB</title>
    <published>2025-06-25T00:00:00+00:00</published>
    <updated>2025-06-25T00:00:00+00:00</updated>
    <author>
        <name></name>
    </author>
    <summary type="html">This guide walks you through a robust procedure to auto-decrypt a LUKS-on-LVM setup at boot with a USB key. It&#039;s the result of trying different approaches of outdated posts, AI suggestions, and many other sources until I found my own way....</summary>
    <content type="html"><![CDATA[
        <p>This guide walks you through a robust procedure to auto-decrypt a LUKS-on-LVM setup at boot with a USB key. It assumes you already have your system set up with LUKS encryption on LVM and a <strong>USB with FAT32 format</strong>.</p>

<p>The core idea is simple: keep a dedicated USB stick at home to unlock your system effortlessly, and leave it behind whenever you head out, so your machine stays securely encrypted on the go.</p>

<p>I extensively tested the procedure in multiple VMs and on real-world installs of Ubuntu 24.04 and Pop!_OS 22.04. Until reaching that point, I repeatedly broke my VM while trying outdated guides, AI-generated suggestions, and other unreliable sources—until I found my own way and then passed it to real setups. Here’s what I tried (and what finally worked with some customization):</p>

<ul>
<li>❌ initramfs hooks</li>
<li>❌ udev scripts</li>
<li>❌ dracut modules</li>
<li>✅ cryptsetup using <code>keyscript</code> option (handled by <code>initramfs-tools</code>)</li>
</ul>

<p>This solution relies on the <code>keyscript</code> option supported by <code>initramfs-tools</code> (not by <code>cryptsetup</code> itself), which allows delegating the decryption process to a custom script in a higher-level and safer way than using low-level <code>initramfs</code> hooks. Although the option is defined in <code>/etc/crypttab</code> and passed to the initramfs layer, <code>cryptsetup</code> itself only accepts raw key data.</p>

<p>Internally, <code>initramfs-tools</code> handles <code>keyscript</code> by executing the specified script and piping its output to <code>cryptsetup</code>, effectively feeding the keyfile through standard input.</p>

<p>Most scripts I came across failed to handle this process in a fault-tolerant way: instead of falling back to the familiar "Enter password" screen when any kind of error happens, they leave you with a broken system.</p>

<p>That’s why I wrote a script that does things properly. It mounts your USB, attempts to load the keyfile up to three times, and if it still fails, gracefully falls back to the standard password prompt you're used to.</p>

<p>Before proceeding, 🚨 <strong>make a full disk backup (not just the partition) using Clonezilla</strong> and <strong>test this solution in a VM</strong> running the same Ubuntu version as yours. This ensures you're familiar with the process and minimizes risk — I personally broke my VM several times during testing for writing this post, but snapshots made rollback easy.</p>

<p>The only requirement for this solution to work is support for the <code>keyscript</code> parameter, which has been available in <code>initramfs-tools</code> since at least Ubuntu 16.04.</p>

<p>✅ Tested on:</p>

<ul>
<li>Ubuntu 22.04 and 24.04 (and flavours: Mate + Xubuntu)</li>
<li>Pop!_OS 22.04</li>
</ul>

<p>These cover <code>cryptsetup</code> versions from <strong>2.4.3</strong> to <strong>2.7.0</strong>. You can verify your version with:</p>

<pre><code class="language-bash">cryptsetup --version
</code></pre>

<h2>1. Creating a secure keyfile</h2>

<p>This command creates a 4MB keyfile filled with cryptographically secure binary data (non-ASCII characters), providing high entropy per byte — making brute-force attacks significantly harder.</p>

<p>💡 <strong>Tip</strong>: Save the keyfile directly to the USB drive that you'll use to decrypt your setup.</p>

<pre><code class="language-text">dd if=/dev/urandom of=/media/usb/secure.key bs=1M count=4 iflag=fullblock
</code></pre>

<h2>2. Downloading the unlock script</h2>

<p>We'll need the unlock script for the <code>keyscript</code> parameter in the <code>crypttab</code> entry.</p>

<p>In systemd-based setups (e.g. <strong>Pop!_OS</strong>), the keyscript must be placed inside <code>/lib/cryptsetup/scripts</code>. In non-systemd setups (e.g. <strong>Ubuntu</strong>), it can technically reside anywhere — but placing it in <code>/lib/cryptsetup/scripts</code> ensures compatibility across systems.</p>

<pre><code class="language-bash">sudo wget -O /lib/cryptsetup/scripts/keyscript.sh \
  https://raw.githubusercontent.com/filisko/cryptsetup-usb-keyscript/main/src/keyscript.sh
</code></pre>

<p>⚠️ <strong>It's critical</strong> that the script is owned by root and has execution permissions:</p>

<pre><code class="language-bash">sudo chown root:root /lib/cryptsetup/scripts/keyscript.sh
sudo chmod 755 /lib/cryptsetup/scripts/keyscript.sh
</code></pre>

<h2>3. Finding your USB's UUID</h2>

<p>We need USB's UUID to tell <code>cryptsetup</code> where to find the keyfile.</p>

<pre><code class="language-bash">sudo blkid
</code></pre>

<p>There will be many lines like the following one. We're interested in the UUID="81D6-413D" part.</p>

<pre><code class="language-text">/dev/sda1: UUID="81D6-413D" BLOCK_SIZE="512" TYPE="vfat" PARTUUID="c371356e-01"
</code></pre>

<h2>3. Updating <code>/etc/crypttab</code> entries</h2>

<p>To enable automatic decryption, we need to edit <code>/etc/crypttab</code> and provide:</p>

<ul>
<li>The LUKS device name (already there).</li>
<li>The UUID of the LUKS-encrypted root partition (already there).</li>
<li>The full keyfile path, using the USB device's UUID: <code>/dev/disk/by-uuid/81D6-413D:/secure.key</code> (must be set).</li>
<li>The necessary options for <code>cryptsetup</code> to invoke the keyscript (must be set).</li>
</ul>

<p>This tells the system where to find the keyfile and how to decrypt at boot.</p>

<pre><code class="language-bash">sudo nano /etc/crypttab
</code></pre>

<p>It’s very likely that your system has an entry like this by default (note <code>luks</code>):</p>

<pre><code class="language-text">dm_crypt-0 UUID=XXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX none luks
</code></pre>

<p>I suggest that you <strong>save it</strong> somewhere or comment it putting <code>#</code> at the beginning of the line.</p>

<p>The first thing that you see in the line is LUKS' device name that we will also need later to add the keyfile inside, so also save it. Common names are:</p>

<ul>
<li>dm_crypt-0 / cryptdrive (Ubuntu)</li>
<li>cryptdata (Pop!_OS)</li>
</ul>

<p>⚠️ <strong>It's crucial</strong> to preserve the original device name (e.g.: dm_crypt-0), UUID, and exact field spacing to avoid boot issues.</p>

<p>Replace (<strong>not add, duplicate device UUIDs or names aren't allowed</strong>) your entry to match the changes of this entry:</p>

<pre><code class="language-text">dm_crypt-0 UUID=XXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX /dev/disk/by-uuid/81D6-413D:/secure.key luks,discard,cipher=aes-xts-plain64,size=256,hash=sha1,keyscript=/lib/cryptsetup/scripts/keyscript.sh,tries=4
</code></pre>

<p>⚠️ The <code>tries=4</code> option is <strong>mandatory</strong> — it ensures the system runs the script 3 times and gives it a chance to gracefully fall back on the 4th try. That 4th attempt is where the script prompts the user to manually enter the password, in case:</p>

<ul>
<li>The USB isn't connected or could not be properly mounted.</li>
<li>The USB is found, but the keyfile isn't.</li>
<li>The keyfile is found, but it's invalid.</li>
</ul>

<p>Double-check the USB UUID and keyscript path, especially if you changed the script location.</p>

<h2>4. Get LUKS' device path</h2>

<p>With LUKS' device name (e.g.: dm_crypt-0) from the <code>crypttab</code> entry in previous step, we will get its device path to add the keyfile into it.</p>

<pre><code class="language-bash">sudo cryptsetup status dm_crypt-0
</code></pre>

<p>The line that we need from the output is: <code>device: /dev/nvme0n1p3</code> where <code>/dev/nvme0n1p3</code> is the device path that we need.</p>

<pre><code class="language-text">/dev/mapper/dm_crypt-0 is active and is in use.
 type:    LUKS2
 cipher:  aes-xts-plain64
 keysize: 512 bits
 key location: keyring
 device:  /dev/nvme0n1p3
 sector size:  512
 offset:  32768 sectors
 size:    1993975808 sectors
 mode:    read/write
</code></pre>

<h2>5. Add the keyfile to LUKS device</h2>

<p>Here we need to set LUKS' device path that we got in the previous step and specify the keyfile that we generated at the very beginning, in step 1.</p>

<p>This will require an existing passphrase to add the new keyfile:</p>

<pre><code class="language-bash">sudo cryptsetup luksAddKey /dev/nvme0n1p3 /media/usb/secure.key
</code></pre>

<p>If "nothing happens" (it actually returns a shell success code) then it worked.</p>

<h2>6. Test the keyfile</h2>

<p>To test that the previous step was done correctly, we can do the following:</p>

<pre><code class="language-bash">sudo cryptsetup luksOpen --test-passphrase --key-file /media/usb/secure.key /dev/nvme0n1p3
</code></pre>

<p>If it works, it won't show anything (only return success in the terminal). If it fails, it will show:</p>

<pre><code class="language-text">No key available with this passphrase.
</code></pre>

<h2>7. Update initramfs</h2>

<p>The last step to set everything up is updating initramfs. Initramfs is a temporary file system with a mini-Linux inside used only during boot. After boot everything is deleted.</p>

<p>Updating initramfs will add to the initramfs image the updated version of <code>/etc/crypttab</code> together with the unlock script that we've previously downloaded inside <code>/lib/cryptsetup</code>.</p>

<p>⚠️ <strong>Warning</strong>: If you see any errors while running the command, it's better to revert to your original <code>/etc/crypttab</code> entry and post a comment in the comments section below this post.</p>

<p>This may take around 1 min:</p>

<pre><code class="language-bash">sudo update-initramfs -u
</code></pre>

<p>To check that our script was added inside initramfs we can run:</p>

<pre><code class="language-bash">sudo lsinitramfs /boot/initrd.img-XXXXXXXX-generic | grep 'keyscript.sh'
</code></pre>

<p><code>lsinitramfs</code> lists the files of initramfs' compressed image. So all the files of the mini-Linux will be listed there.</p>

<p>And something like this should be printed in our console (you can Ctrl-C to cancel the grep after finding it):</p>

<pre><code class="language-text">usr/lib/cryptsetup/scripts/keyscript.sh
</code></pre>

<h2>8. Reboot</h2>

<p>This is the last necessary step.</p>

<p>Simply reboot the USB and your setup should be automatically decrypted! 🎉🥳</p>

<pre><code class="language-bash">sudo reboot
</code></pre>

<p>If all went well, please comment below with your Ubuntu (or other distro) version and your cryptsetup version.</p>

<h2>9. Logs</h2>

<p>Something cool that I figured out is a way to log messages inside initramfs. Usually this is almost impossible because, as I said earlier, initramfs (a temporary filesystem) is cleaned up right after boot, so anything written anywhere will be removed.</p>

<p>I realized that logs can be sent directly to the kernel using <code>/dev/kmsg</code>, so that later on, after boot, you can grep logs doing the following:</p>

<pre><code class="language-bash">sudo dmesg | grep 'unlock.sh'
</code></pre>

<p>This is pretty cool because either on success or failure (you had to manually enter the password) you can check the logs.</p>

<h3>Logs when using the USB key normally:</h3>

<pre><code class="language-text">[    8.199775] unlock.sh: Attempt #1
[    8.213481] unlock.sh: Using keyfile: /mnt/unlock-usb/secure.key
</code></pre>

<h3>Logs when USB device was not found or simply choosing to manually introduce the password</h3>

<pre><code class="language-text">[    3.258277] unlock.sh: Attempt #1
[    6.271922] unlock.sh: Device for decryption not found: /dev/disk/by-uuid/81D6-413D
[    6.941412] unlock.sh: Proceeding to ask for manually entering the password
</code></pre>

<h3>Logs when plugging in a USB without the keyfile</h3>

<pre><code class="language-text">[    3.234567] unlock.sh: Attempt #1
[    6.274412] unlock.sh: Keyfile not found at: /mnt/unlock-usb/secure.key
[    6.954763] unlock.sh: Proceeding to ask for manually entering the password
</code></pre>

<h3>Logs when the USB could not be mounted on boot</h3>

<pre><code class="language-text">[    3.112245] unlock.sh: Attempt #1
[    6.514122] unlock.sh: Failed to mount device: /dev/disk/by-uuid/81D6-413D at /mnt/unlock-usb
[    6.613212] unlock.sh: Proceeding to ask for manually entering the password
</code></pre>

<h3>Logs when the keyfile is located but incorrect</h3>

<pre><code class="language-text">[    8.082461] unlock.sh: Attempt #1
[    8.096964] unlock.sh: Using keyfile: /mnt/unlock-usb/secure.key
[   13.501285] unlock.sh: Attempt #2
[   13.511812] unlock.sh: Using keyfile: /mnt/unlock-usb/secure.key
[   18.349567] unlock.sh: Attempt #3
[   18.361704] unlock.sh: Using keyfile: /mnt/unlock-usb/secure.key
[   23.205031] unlock.sh: Max retries (3) reached. Proceeding to ask for manually entering the password.
</code></pre>

<h1>Share your thoughts</h1>

<p>Would it be interesting to consider any USB as valid? Or it's better to restrict it by UUID as we're doing already?</p>

<p>Special thanks to Guilhem Moulin, from the <a href="https://cryptsetup-team.pages.debian.net/cryptsetup/README.initramfs.html">official Cryptsetup Team at Debian</a>, for helping me clear up some doubts about the behavior of cryptsetup.</p>

    ]]></content>
</entry>
            <entry>
    <id>https://filis.me/posts/how-to-setup-database-integration-tests-in-vanilla-php</id>
    <link type="text/html" rel="alternate" href="https://filis.me/posts/how-to-setup-database-integration-tests-in-vanilla-php" />
    <title>How to set up Database Integration Tests in vanilla PHP</title>
    <published>2025-11-29T00:00:00+00:00</published>
    <updated>2025-11-29T00:00:00+00:00</updated>
    <author>
        <name></name>
    </author>
    <summary type="html">A practical guide to bringing real database integration tests into legacy or framework-less PHP projects using the same proven technique that major PHP frameworks rely on....</summary>
    <content type="html"><![CDATA[
        <p>In this guide I’ll show you how to run fast, isolated, high-quality Database Integration Tests in legacy or framework-less PHP projects. Only Doctrine or PDO needed, and a small but incredibly powerful trick used by many battle-tested frameworks across different programming languages ecosystems.</p>

<p>One reason this is a very solid approach is that it provides the guarantees of real database integration tests — transactions, persisted data, and SQL queries hitting a real database — while keeping execution times extremely low. This makes it ideal for large test suites, continuous refactoring, and yes, even TDD, because it preserves your development flow through a fast feedback loop.</p>

<p>Also, this approach works exceptionally well in legacy projects. Most legacy codebases lack a Testing Foundation. With this technique, you can introduce high-level database integration tests even into very old or badly coupled systems.</p>

<p>Please note that before going 'all-in' into this approach, I've tried different alternatives, here are two of them:</p>

<h3>❌ <strong>SQLite's In-Memory Database support</strong></h3>

<p>SQLite can run entirely in memory, meaning you can have a fully isolated database instance that lives only in RAM.</p>

<p>For example, you could run your Database Integration Tests across 16 parallel processes, each with its own in-memory database.</p>

<p>This is EXTREMELY fast — and a perfectly valid approach if SQLite is your primary database — but there’s a significant gap between SQLite and, for example, PostgreSQL. In behaviour, data types, and SQL semantics. MySQL is somewhat closer to SQLite, but still not equivalent.</p>

<p>If your main database is other than SQLite and you choose this approach, you’ll need to limit your queries to the subset of features SQLite supports. And even then, there will always be a non-negligible mismatch, which may keep your confidence from reaching 100%.</p>

<p>In PHP, you can set up SQLite's in-memory Database like this:</p>

<pre><code class="language-php">$pdo = new PDO('sqlite::memory:');
</code></pre>

<p>Just be aware that with any in-memory database testing setup, you’ll need to recreate the schema for every run, as nothing is persisted.</p>

<p>I suggest that you take a look at the official documentation for more details:</p>

<ul>
<li><a href="https://www.php.net/manual/en/ref.pdo-sqlite.connection.php">php.net - PDO SQLite</a></li>
<li><a href="https://www.sqlite.org/inmemorydb.html">sqlite.org - In-Memory database</a></li>
</ul>

<h3>❌ <strong>Vimeo's In-Memory MySQL engine</strong></h3>

<p>I was genuinely surprised when I came across this project. And the good part is that I can speak from experience, having used it for a couple of months. Vimeo describes it as:</p>

<blockquote>
  <p>A MySQL engine written in pure PHP.</p>
</blockquote>

<p>To quickly illustrate the main idea:</p>

<pre><code class="language-php">// use a class specific to your current PHP version (APIs changed in major versions)
$pdo = new \Vimeo\MysqlEngine\Php8\FakePdo($dsn, $user, $password);
// currently supported attributes
$pdo-&gt;setAttribute(\PDO::ATTR_CASE, \PDO::CASE_LOWER);
$pdo-&gt;setAttribute(\PDO::ATTR_EMULATE_PREPARES, false);
</code></pre>

<p>The library provides its own PDO implementation, which effectively acts as the interface to Vimeo’s MySQL engine under the hood. In theory, it works the same way as a regular PDO instance, and you can generally use it anywhere you would use native PDO.</p>

<p>Some issues I've noticed, though:</p>

<ul>
<li><strong>Limited capabilities.</strong> Its feature set is much smaller than native PDO. Only a few of the most common PDO attributes are supported.</li>
<li><strong>Unclear error messages.</strong> The library throws PHP-level exceptions instead of real SQL errors, and they’re often not very intuitive. For example, an SQL syntax error or assigning <code>NULL</code> to a non-nullable column produces a library-generated exception rather than the usual MySQL error message — and these can be confusing at first.</li>
<li><strong>Learning curve.</strong> It takes some time to become familiar with its error patterns before you can be truly productive.</li>
<li><strong>MySQL only.</strong> It only supports MySQL type-of databases, so it’s not suitable if your main database is PostgreSQL, SQLite, or anything else.</li>
</ul>

<p>On the other hand:</p>

<ul>
<li><strong>It’s extremely fast</strong> — there’s no network or database server involved.</li>
<li><strong>It requires no infrastructure changes</strong> — you just need to recreate the schema each time since everything lives in memory.</li>
<li><strong>It’s reliable</strong> — having been used in production for years and backed by Vimeo.</li>
</ul>

<p>I recommend you to take a deeper look and see their real motivation:</p>

<ul>
<li><a href="https://github.com/vimeo/php-mysql-engine?tab=readme-ov-file#motivation">GitHub - vimeo/php-mysql-engine</a></li>
</ul>

<h1>✅ Transactional Database Integration Tests</h1>

<p>This is the chosen approach of this guide.</p>

<p>As you may have noticed, all the previous options come with significant limitations. Unless you choose the SQLite in-memory approach and SQLite is your primary database, none of them provides a 100% trustworthy integration test.</p>

<p>The only way to guarantee fully reliable tests is to interact with your database — the same your application uses. This approach does exactly that and works with any database system that supports transactions.</p>

<p>The idea behind this technique is surprisingly simple. At its core, it looks like this:</p>

<pre><code class="language-php">public function test_user_registration(): void
{
    $pdo = new PDO(...);

    // start transaction
    $pdo-&gt;beginTransaction();

    // interact with the database performing real operations
    $stmt = $pdo-&gt;prepare('INSERT INTO users (name) VALUES (:name)');
    $stmt-&gt;execute(['name' =&gt; 'John']);

    // the user was inserted, we can do some assertions
    $this-&gt;assertCount(1, $pdo-&gt;query('SELECT * FROM users')-&gt;fetchAll());

    // our test has finished, we roll back everything
    $pdo-&gt;rollBack();
}
</code></pre>

<p>In other words:</p>

<ol>
<li>Starts a database transaction,</li>
<li>performs a real insert,</li>
<li>gets a real auto-incremented ID,</li>
<li>and finally rolls everything back.</li>
</ol>

<p>This is real database interaction with almost zero side effects — and, most importantly, enables a fast and reliable feedback loop that keeps your development flow smooth. The only noticeable side effect that I realized is that if you use auto-incremented IDs they will keep increasing.</p>

<p>Modern testing setups wrap <code>beginTransaction()</code> and <code>rollBack()</code> inside methods such as <code>setUp()</code> and <code>tearDown()</code> which are specific to the Testing Frameworks, in this case, PHPUnit. But the underlying mechanism is exactly the same.</p>

<p>Also, you’ll probably want to separate your testing and development databases. If you mix them (use you development database for tests), your tests won’t start from a clean state, and you’ll eventually end up with incorrect assumptions and unreliable results.</p>

<h1>Who uses this approach?</h1>

<p>This technique is not new. In fact, it’s well-established and widely used across many battle-tested frameworks and tools in both PHP and non-PHP ecosystems. Frameworks like Ruby on Rails, Django (Python) and Spring Boot (Java) rely on the same idea: run each test inside a database transaction and roll it back at the end.</p>

<p>Over the years, this pattern has proven to be one of the fastest, cleanest, and most reliable ways to write real database integration tests.</p>

<p>Here are some well-known examples:</p>

<h2>Ruby On Rails</h2>

<p>Since the early versions of Rails (2005–2006, around its initial release), this mechanism has been supported.</p>

<p>This approach allowed Rails applications to scale their test suites without suffering the performance penalties of repeatedly creating or truncating tables, and it helped popularize transactional testing patterns in many other frameworks.</p>

<blockquote>
  <p>By default, Rails automatically wraps tests in a database transaction that is rolled back once completed. This makes tests independent of each other and means that changes to the database are only visible within a single test.</p>
</blockquote>

<p><strong>Reference:</strong> <a href="https://guides.rubyonrails.org/testing.html#transactions">Ruby on Rails - Transactional Database Tests</a></p>

<h2>WordPress</h2>

<p>Since 2017, WordPress’ PHPUnit test suite has adopted this transactional approach: each test starts a MySQL transaction and rolls it back after execution. This ensures real SQL behavior while keeping the database clean between tests.</p>

<blockquote>
  <p>Database modifications made during test, on the other hand, are not persistent. Before each test, the suite opens a MySQL transaction (<code>START TRANSACTION</code>) with autocommit disabled, <strong>and at the end of each test the transaction is rolled back</strong> (<code>ROLLBACK</code>). This means that database operations performed from within a test, such as the creation of test fixtures, are discarded after each test.</p>
</blockquote>

<p><strong>Reference:</strong> <a href="https://make.wordpress.org/core/handbook/testing/automated-testing/writing-phpunit-tests/#database">WordPress Handbook - Testing with PHPUnit</a></p>

<h2>Laravel</h2>

<p>The <code>Illuminate\Foundation\Testing\RefreshDatabase</code> Trait in Laravel also does exactly what we described. It wraps the test within a Database transaction, and rolls back everything at the end of the test.</p>

<blockquote>
  <p>The <code>Illuminate\Foundation\Testing\RefreshDatabase</code> trait does not migrate your database if your schema is up to date. Instead, <strong>it will only execute the test within a database transaction</strong>. Therefore, any records added to the database by test cases that do not use this trait may still exist in the database.</p>
</blockquote>

<p><strong>Reference:</strong> <a href="https://laravel.com/docs/11.x/database-testing#resetting-the-database-after-each-test">Laravel - Resetting the Database after each test</a></p>

<h2>Symfony</h2>

<p>In the Symfony ecosystem, this approach is commonly implemented through the <code>dama/doctrine-test-bundle</code>, a bundle that — as of today — has more than 33 million downloads.</p>

<p>It is also one of the most decoupled, enterprise-grade solutions available. In practice, this means you can use the 'non-Symfony' part of this library in virtually any project, benefiting from the  level of robustness and reliability that it has gained over the years.</p>

<p>You might hesitate about the Doctrine requirement — but there’s an important reason for it. This whole approach relies on database transactions, and that raises an immediate question: <strong>what happens if your application performs nested transactions?</strong></p>

<p>This is exactly where the library shines. It handles transactional tests, even when your code opens its own transactions internally. Thanks to Doctrine’s DBAL middleware and its savepoint support, nested transactions work seamlessly on drivers such as PostgreSQL and MySQL.</p>

<ul>
<li>Reference: <a href="https://github.com/dmaicher/doctrine-test-bundle">GitHub - dmaicher/doctrine-test-bundle</a></li>
</ul>

<h1>How to setup <code>dmaicher/doctrine-test-bundle</code> in your framework-agnostic project</h1>

<p>In this section, we’ll focus on how to configure this library for any framework-agnostic project — whether it’s a legacy codebase or a modern project where you intentionally chose to keep things minimal.</p>

<p>As a matter of fact, <a href="https://github.com/dmaicher/doctrine-test-bundle/issues/318">I actually posted a question in library’s GitHub repository</a> asking about this exact use case, and David Maicher, the official maintainer, was kind enough to help me through the details. What follows is essentially the result of that exchange.</p>

<p>This assumes you already have a working Doctrine connection in place.</p>

<p>Install the composer package:</p>

<pre><code class="language-bash">composer require dama/doctrine-test-bundle:^8.4 --dev
</code></pre>

<h2>Step 1: Add library's PHPUnit extension to <code>phpunit.xml</code></h2>

<p>This is required so that PHPUnit automatically rolls back the database transaction after each test.</p>

<p>Add the following <code>&lt;extensions&gt;</code> block — or simply add the <code>&lt;bootstrap class&gt;</code> to your existing <code>&lt;extensions&gt;</code> section if you already have one — to your <code>phpunit.xml</code> (or the one you use) file:</p>

<pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;phpunit&gt;
    &lt;!-- ...other stuff ... --&gt;
    &lt;extensions&gt;
        &lt;bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/&gt;
    &lt;/extensions&gt;
&lt;/phpunit&gt;
</code></pre>

<h2>Step 2: Set up the Doctrine connection using the library’s components</h2>

<p>The following code shows what you would normally want to have in your “Doctrine connection” setup.</p>

<p>The key parts are:</p>

<ul>
<li>the <code>dama.connection_key</code> parameter (it can be set to anything, but it must be present)</li>
<li>adding the bundle’s DBAL Middleware</li>
<li>calling <code>setKeepStaticConnections(true)</code></li>
</ul>

<p>If you miss any of these, the integration test won't work.</p>

<pre><code class="language-php">use Doctrine\ORM\Configuration;
use Doctrine\ORM\ORMSetup;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\DriverManager;

function getDoctrineConnection(Environment $environment): Connection
{
    $parameters = [
        'driver'   =&gt; 'pdo_pgsql',
        'host' =&gt; '127.0.0.1',
        'user'     =&gt; 'postgresql',
        'password' =&gt; 'postgresql',
        'dbname'   =&gt; 'app_prod',
    ];

    // this is not relevant for this example, but if you use Doctrine, you probably use the ORM too.
    // And this is just to make it more similar to your context.
    $config = ORMSetup::createAttributeMetadataConfiguration(
        paths: [$domainEntitiesPath],
        isDevMode: $environment-&gt;not(Environment::production),
    );

    // you will probably want to have a check similar to this
    if ($environment-&gt;is(Environment::testing)) {
        // also, you probably want to switch to an empty, different database for testing!
        $parameters['dbname'] = 'app_tests';

        // set a connection key
        $parameters['dama.connection_key'] = 'anything-is-ok',

        // add the DBAL middleware
        $config-&gt;setMiddlewares([
            new \DAMA\DoctrineTestBundle\Doctrine\DBAL\Middleware(),
        ]);

        // keep static connections across tests
        \DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver::setKeepStaticConnections(true);
    }

    return DriverManager::getConnection($parameters, $config);
}
</code></pre>

<p>This is it. You don’t need anything else.</p>

<p>This is a complete example of how a Database Integration Test now looks:</p>

<pre><code class="language-php">use PHPUnit\Framework\TestCase;

class SomeTest extends TestCase
{
    public function test_doctrine_connection(): void
    {
        $connection = getDoctrineConnection(Environment::testing);

        // Insert a real row into the database
        $connection-&gt;insert('users', [
            'name' =&gt; 'John',
        ]);

        // Fetch the last inserted ID
        $userId = $connection-&gt;lastInsertId();

        // Verify the row exists
        $name = $connection-&gt;fetchOne(
            'SELECT name FROM users WHERE id = :id',
            ['id' =&gt; $userId]
        );

        $this-&gt;assertEquals('John', $name);

        // No cleanup needed — everything will be rolled back automatically
    }
}
</code></pre>

<p>I hope this post helped you understand how to perform real database integration tests in PHP without relying on any framework.</p>

<h1>Share your thoughts</h1>

<p>Did you find this approach useful? Would you like to hear about other variations?</p>

<p>If you tried it or ran into anything unexpected, I'd be happy hear how it went — your experience helps keep this post accurate and helpful for others.</p>

<p>And if you’re applying this technique in a real project — especially a legacy one — feel free to share your story. This can encourage others to adopt it as well.</p>

<p>Special thanks to <a href="https://github.com/dmaicher">David Maicher (@dmaicher)</a>, the maintainer of the <code>doctrine-test-bundle</code> project, for helping clarify how to use the library in a framework-agnostic context.</p>

    ]]></content>
</entry>
    </feed>
