diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 00000000..a103e397
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,9 @@
+{
+    "image": "mcr.microsoft.com/devcontainers/universal:2",
+    "features": {
+      "ghcr.io/devcontainers/features/php:1": {
+        "version": "8.3",
+        "installComposer": true
+      }
+    }
+  }
\ No newline at end of file
diff --git a/exercises/practice/list-ops/ListOpsTest.php b/exercises/practice/list-ops/ListOpsTest.php
index 4d0f35ea..e2a2cb8f 100644
--- a/exercises/practice/list-ops/ListOpsTest.php
+++ b/exercises/practice/list-ops/ListOpsTest.php
@@ -33,231 +33,311 @@ public static function setUpBeforeClass(): void
         require_once 'ListOps.php';
     }
 
+
     /**
      * @testdox append entries to a list and return the new list -> empty lists
      */
-    public function testAppendEmptyLists()
+    public function testAppendEntriesToAListAndReturnTheNewListWithEmptyLists()
     {
         $listOps = new ListOps();
-        $this->assertEquals([], $listOps->append([], []));
+        $list1 = [];
+        $list2 = [];
+        
+        $result = $listOps->append($list1, $list2);
+
+        $this->assertEquals([], $result);
     }
 
     /**
      * @testdox append entries to a list and return the new list -> list to empty list
      */
-    public function testAppendNonEmptyListToEmptyList()
+    public function testAppendEntriesToAListAndReturnTheNewListWithListToEmptyList()
     {
         $listOps = new ListOps();
-        $this->assertEquals([1, 2, 3, 4], $listOps->append([1, 2, 3, 4], []));
+        $list1 = [];
+        $list2 = [1, 2, 3, 4];
+        
+        $result = $listOps->append($list1, $list2);
+
+        $this->assertEquals([1, 2, 3, 4], $result);
     }
 
     /**
      * @testdox append entries to a list and return the new list -> empty list to list
      */
-    public function testAppendEmptyListToNonEmptyList()
+    public function testAppendEntriesToAListAndReturnTheNewListWithEmptyListToList()
     {
         $listOps = new ListOps();
-        $this->assertEquals([1, 2, 3, 4], $listOps->append([], [1, 2, 3, 4]));
+        $list1 = [1, 2, 3, 4];
+        $list2 = [];
+        
+        $result = $listOps->append($list1, $list2);
+
+        $this->assertEquals([1, 2, 3, 4], $result);
     }
 
     /**
      * @testdox append entries to a list and return the new list -> non-empty lists
      */
-    public function testAppendNonEmptyLists()
+    public function testAppendEntriesToAListAndReturnTheNewListWithNonEmptyLists()
     {
         $listOps = new ListOps();
-        $this->assertEquals([1, 2, 2, 3, 4, 5], $listOps->append([1, 2], [2, 3, 4, 5]));
+        $list1 = [1, 2];
+        $list2 = [2, 3, 4, 5];
+        
+        $result = $listOps->append($list1, $list2);
+
+        $this->assertEquals([1, 2, 2, 3, 4, 5], $result);
     }
 
     /**
      * @testdox concatenate a list of lists -> empty list
      */
-    public function testConcatEmptyLists()
+    public function testConcatenateAListOfListsWithEmptyList()
     {
         $listOps = new ListOps();
-        $this->assertEquals([], $listOps->concat([], []));
+        $lists = [];
+        
+        $result = $listOps->concat($lists);
+
+        $this->assertEquals([], $result);
     }
 
     /**
      * @testdox concatenate a list of lists -> list of lists
      */
-    public function testConcatLists()
+    public function testConcatenateAListOfListsWithListOfLists()
     {
         $listOps = new ListOps();
-        $this->assertEquals([1, 2, 3, 4, 5, 6], $listOps->concat([1, 2], [3], [], [4, 5, 6]));
+        $lists = [[1, 2], [3], [], [4, 5, 6]];
+        
+        $result = $listOps->concat($lists);
+
+        $this->assertEquals([1, 2, 3, 4, 5, 6], $result);
     }
 
     /**
      * @testdox concatenate a list of lists -> list of nested lists
      */
-    public function testConcatNestedLists()
+    public function testConcatenateAListOfListsWithListOfNestedLists()
     {
         $listOps = new ListOps();
-        $this->assertEquals([[1], [2], [3], [], [4, 5, 6]], $listOps->concat([[1], [2]], [[3]], [[]], [[4, 5, 6]]));
+        $lists = [[[1], [2]], [[3]], [[]], [[4, 5, 6]]];
+        
+        $result = $listOps->concat($lists);
+
+        $this->assertEquals([[1], [2], [3], [], [4, 5, 6]], $result);
     }
 
     /**
      * @testdox filter list returning only values that satisfy the filter function -> empty list
      */
-    public function testFilterEmptyList()
+    public function testFilterListReturningOnlyValuesThatSatisfyTheFilterFunctionWithEmptyList()
     {
         $listOps = new ListOps();
-        $this->assertEquals(
-            [],
-            $listOps->filter(static fn ($el) => $el % 2 === 1, [])
-        );
+        $list = [];
+        $function = static fn ($el) => $el % 2 === 1;
+        
+        $result = $listOps->filter($list, $function);
+
+        $this->assertEquals([], $result);
     }
 
     /**
-     * @testdox filter list returning only values that satisfy the filter function -> non empty list
+     * @testdox filter list returning only values that satisfy the filter function -> non-empty list
      */
-    public function testFilterNonEmptyList()
+    public function testFilterListReturningOnlyValuesThatSatisfyTheFilterFunctionWithNonEmptyList()
     {
         $listOps = new ListOps();
-        $this->assertEquals(
-            [1, 3, 5],
-            $listOps->filter(static fn ($el) => $el % 2 === 1, [1, 2, 3, 5])
-        );
+        $list = [1, 2, 3, 5];
+        $function = static fn ($el) => $el % 2 === 1;
+        
+        $result = $listOps->filter($list, $function);
+
+        $this->assertEquals([1, 3, 5], $result);
     }
 
     /**
      * @testdox returns the length of a list -> empty list
      */
-    public function testLengthEmptyList()
+    public function testReturnsTheLengthOfAListWithEmptyList()
     {
         $listOps = new ListOps();
-        $this->assertEquals(0, $listOps->length([]));
+        $list = [];
+        
+        $result = $listOps->length($list);
+
+        $this->assertEquals(0, $result);
     }
 
     /**
      * @testdox returns the length of a list -> non-empty list
      */
-    public function testLengthNonEmptyList()
+    public function testReturnsTheLengthOfAListWithNonEmptyList()
     {
         $listOps = new ListOps();
-        $this->assertEquals(4, $listOps->length([1, 2, 3, 4]));
+        $list = [1, 2, 3, 4];
+        
+        $result = $listOps->length($list);
+
+        $this->assertEquals(4, $result);
     }
 
     /**
-     * @testdox returns a list of elements whose values equal the list value transformed by the mapping function -> empty list
+     * @testdox return a list of elements whose values equal the list value transformed by the mapping function -> empty list
      */
-    public function testMapEmptyList()
+    public function testReturnAListOfElementsWhoseValuesEqualTheListValueTransformedByTheMappingFunctionWithEmptyList()
     {
         $listOps = new ListOps();
-        $this->assertEquals(
-            [],
-            $listOps->map(static fn ($el) => $el + 1, [])
-        );
+        $list = [];
+        $function = static fn ($el) => $el + 1;
+        
+        $result = $listOps->map($list, $function);
+
+        $this->assertEquals([], $result);
     }
 
     /**
-     * @testdox returns a list of elements whose values equal the list value transformed by the mapping function -> non-empty list
+     * @testdox return a list of elements whose values equal the list value transformed by the mapping function -> non-empty list
      */
-    public function testMapNonEmptyList()
+    public function testReturnAListOfElementsWhoseValuesEqualTheListValueTransformedByTheMappingFunctionWithNonEmptyList()
     {
         $listOps = new ListOps();
-        $this->assertEquals(
-            [2, 4, 6, 8],
-            $listOps->map(static fn ($el) => $el + 1, [1, 3, 5, 7])
-        );
+        $list = [1, 3, 5, 7];
+        $function = static fn ($el) => $el + 1;
+        
+        $result = $listOps->map($list, $function);
+
+        $this->assertEquals([2, 4, 6, 8], $result);
     }
 
     /**
      * @testdox folds (reduces) the given list from the left with a function -> empty list
      */
-    public function testFoldlEmptyList()
+    public function testFoldsReducesTheGivenListFromTheLeftWithAFunctionWithEmptyList()
     {
         $listOps = new ListOps();
-        $this->assertEquals(
-            2,
-            $listOps->foldl(static fn ($acc, $el) => $el * $acc, [], 2)
-        );
+        $list = [];
+        $initial = 2;
+        $function = static fn ($acc, $el) => $el * $acc;
+        
+        $result = $listOps->foldl($list, $initial, $function);
+
+        $this->assertEquals(2, $result);
     }
 
     /**
      * @testdox folds (reduces) the given list from the left with a function -> direction independent function applied to non-empty list
      */
-    public function testFoldlDirectionIndependentNonEmptyList()
+    public function testFoldsReducesTheGivenListFromTheLeftWithAFunctionWithDirectionIndependentFunctionAppliedToNonEmptyList()
     {
         $listOps = new ListOps();
-        $this->assertEquals(
-            15,
-            $listOps->foldl(static fn ($acc, $el) => $acc + $el, [1, 2, 3, 4], 5)
-        );
+        $list = [1, 2, 3, 4];
+        $initial = 5;
+        $function = static fn ($acc, $el) => $el + $acc;
+        
+        $result = $listOps->foldl($list, $initial, $function);
+
+        $this->assertEquals(15, $result);
     }
 
     /**
      * @testdox folds (reduces) the given list from the left with a function -> direction dependent function applied to non-empty list
      */
-    public function testFoldlDirectionDependentNonEmptyList()
+    public function testFoldsReducesTheGivenListFromTheLeftWithAFunctionWithDirectionDependentFunctionAppliedToNonEmptyList()
     {
         $listOps = new ListOps();
-        $this->assertEquals(
-            64,
-            $listOps->foldl(static fn ($acc, $el) => $el / $acc, [1, 2, 3, 4], 24)
-        );
+        $list = [1, 2, 3, 4];
+        $initial = 24;
+        $function = static fn ($acc, $el) => $el / $acc;
+        
+        $result = $listOps->foldl($list, $initial, $function);
+
+        $this->assertEquals(64, $result);
     }
 
     /**
      * @testdox folds (reduces) the given list from the right with a function -> empty list
      */
-    public function testFoldrEmptyList()
+    public function testFoldsReducesTheGivenListFromTheRightWithAFunctionWithEmptyList()
     {
         $listOps = new ListOps();
-        $this->assertEquals(
-            2,
-            $listOps->foldr(static fn ($acc, $el) => $el * $acc, [], 2)
-        );
+        $list = [];
+        $initial = 2;
+        $function = static fn ($acc, $el) => $el * $acc;
+        
+        $result = $listOps->foldr($list, $initial, $function);
+
+        $this->assertEquals(2, $result);
     }
 
     /**
      * @testdox folds (reduces) the given list from the right with a function -> direction independent function applied to non-empty list
      */
-    public function testFoldrDirectionIndependentNonEmptyList()
+    public function testFoldsReducesTheGivenListFromTheRightWithAFunctionWithDirectionIndependentFunctionAppliedToNonEmptyList()
     {
         $listOps = new ListOps();
-        $this->assertEquals(
-            15,
-            $listOps->foldr(static fn ($acc, $el) => $acc + $el, [1, 2, 3, 4], 5)
-        );
+        $list = [1, 2, 3, 4];
+        $initial = 5;
+        $function = static fn ($acc, $el) => $el + $acc;
+        
+        $result = $listOps->foldr($list, $initial, $function);
+
+        $this->assertEquals(15, $result);
     }
 
     /**
      * @testdox folds (reduces) the given list from the right with a function -> direction dependent function applied to non-empty list
      */
-    public function testFoldrDirectionDependentNonEmptyList()
+    public function testFoldsReducesTheGivenListFromTheRightWithAFunctionWithDirectionDependentFunctionAppliedToNonEmptyList()
     {
         $listOps = new ListOps();
-        $this->assertEquals(
-            9,
-            $listOps->foldr(static fn ($acc, $el) => $el / $acc, [1, 2, 3, 4], 24)
-        );
+        $list = [1, 2, 3, 4];
+        $initial = 24;
+        $function = static fn ($acc, $el) => $el / $acc;
+        
+        $result = $listOps->foldr($list, $initial, $function);
+
+        $this->assertEquals(9, $result);
     }
 
     /**
-     * @testdox reverse the elements of a list -> empty list
+     * @testdox reverse the elements of the list -> empty list
      */
-    public function testReverseEmptyList()
+    public function testReverseTheElementsOfTheListWithEmptyList()
     {
         $listOps = new ListOps();
-        $this->assertEquals([], $listOps->reverse([]));
+        $list = [];
+        
+        $result = $listOps->reverse($list);
+
+        $this->assertEquals([], $result);
     }
 
     /**
-     * @testdox reverse the elements of a list -> non-empty list
+     * @testdox reverse the elements of the list -> non-empty list
      */
-    public function testReverseNonEmptyList()
+    public function testReverseTheElementsOfTheListWithNonEmptyList()
     {
         $listOps = new ListOps();
-        $this->assertEquals([7, 5, 3, 1], $listOps->reverse([1, 3, 5, 7]));
+        $list = [1, 3, 5, 7];
+        
+        $result = $listOps->reverse($list);
+
+        $this->assertEquals([7, 5, 3, 1], $result);
     }
 
     /**
-     * @testdox reverse the elements of a list -> list of lists is not flattened
+     * @testdox reverse the elements of the list -> list of lists is not flattened
      */
-    public function testReverseNonEmptyListIsNotFlattened()
+    public function testReverseTheElementsOfTheListWithListOfListsIsNotFlattened()
     {
         $listOps = new ListOps();
-        $this->assertEquals([[4, 5, 6], [], [3], [1, 2]], $listOps->reverse([[1, 2], [3], [], [4, 5, 6]]));
+        $list = [[1, 2], [3], [], [4, 5, 6]];
+        
+        $result = $listOps->reverse($list);
+
+        $this->assertEquals([[4, 5, 6], [], [3], [1, 2]], $result);
+    }
+
     }
-}
diff --git a/exercises/practice/list-ops/ListOpsTest.php.twig b/exercises/practice/list-ops/ListOpsTest.php.twig
new file mode 100644
index 00000000..056bf27d
--- /dev/null
+++ b/exercises/practice/list-ops/ListOpsTest.php.twig
@@ -0,0 +1,65 @@
+<?php
+
+{% set callbacks = {
+    '(x) -> x modulo 2 == 1': 'static fn ($el) => $el % 2 === 1',
+    '(x) -> x + 1': 'static fn ($el) => $el + 1',
+    '(acc, el) -> el * acc': 'static fn ($acc, $el) => $el * $acc',
+    '(acc, el) -> el + acc': 'static fn ($acc, $el) => $el + $acc',
+    '(acc, el) -> el / acc': 'static fn ($acc, $el) => $el / $acc',
+}
+-%}
+
+/*
+ * By adding type hints and enabling strict type checking, code can become
+ * easier to read, self-documenting and reduce the number of potential bugs.
+ * By default, type declarations are non-strict, which means they will attempt
+ * to change the original type to match the type specified by the
+ * type-declaration.
+ *
+ * In other words, if you pass a string to a function requiring a float,
+ * it will attempt to convert the string value to a float.
+ *
+ * To enable strict mode, a single declare directive must be placed at the top
+ * of the file.
+ * This means that the strictness of typing is configured on a per-file basis.
+ * This directive not only affects the type declarations of parameters, but also
+ * a function's return type.
+ *
+ * For more info review the Concept on strict type checking in the PHP track
+ * <link>.
+ *
+ * To disable strict typing, comment out the directive below.
+ */
+
+declare(strict_types=1);
+
+use PHPUnit\Framework\ExpectationFailedException;
+
+class ListOpsTest extends PHPUnit\Framework\TestCase
+{
+    public static function setUpBeforeClass(): void
+    {
+        require_once 'ListOps.php';
+    }
+
+
+    {% for case0 in cases -%}
+    {% for case in case0.cases -%}
+    /**
+     * @testdox {{ case0.description }} -> {{ case.description }}
+     */
+    public function {{ testfn(case0.description ~ ' with ' ~ case.description) }}()
+    {
+        $listOps = new ListOps();
+        {% for property, value in case.input -%}
+        ${{ property }} = {{ property == 'function' ? callbacks[value] : export(value) }};
+        {% endfor %}
+
+        $result = $listOps->{{ case.property }}({{ case.input | keys | map(p => '$' ~ p) | join(', ')}});
+
+        $this->assertEquals({{ export(case.expected) }}, $result);
+    }
+
+    {% endfor -%}
+    {% endfor -%}
+}
diff --git a/test-generator/.gitignore b/test-generator/.gitignore
new file mode 100644
index 00000000..1940dfd2
--- /dev/null
+++ b/test-generator/.gitignore
@@ -0,0 +1,3 @@
+.phpunit.cache/
+.phpcs-cache
+vendor/
diff --git a/test-generator/README.md b/test-generator/README.md
new file mode 100644
index 00000000..dd1b0a35
--- /dev/null
+++ b/test-generator/README.md
@@ -0,0 +1,26 @@
+TODO:
+- [ ] Readme
+  - [ ] Requirements (php 8.3)
+  - [ ] Usage `php test-generator/main.php exercises/practice/list-ops/ /home/codespace/.cache/exercism/configlet/problem-specifications/exercises/list-ops/canonical-data.json -vv`
+  - [ ] https://twig.symfony.com/
+  - [ ] custom functions `export` / `testf`
+- [ ] CI (generator)
+  - [ ] `phpstan`
+  - [ ] `phpcs`
+  - [ ] `phpunit`
+- [ ] CI (exercises): iterate over each exercise and run the generator in check mode
+- [ ] Write tests
+- [ ] Path to convert existing exercises to the test-generator
+- [ ] `@TODO`
+- [ ] Upgrade https://github.com/brick/varexporter
+- [ ] TOML Library for php (does not seem to exist any maitained library)
+- [ ] Default templates:
+  - [ ] Test function header (automatic docblock, automatic name)
+- [ ] Going further
+  - [ ] Skip re-implements
+  - [x] Read .meta/tests.toml to skip  `include=false` cases by uuid
+  - [ ] Ensure correctness between toml and effectively generated files
+  - [ ] Default templates to include (strict_types header, require_once based on config, testfn header [testdox, uuid, task_id])
+  - [ ] devcontainer for easy contribution in github codespace directly
+  - [ ] Automatically fetch configlet and exercise informations
+  - [x] Disable twig automatic isset
diff --git a/test-generator/composer.json b/test-generator/composer.json
new file mode 100644
index 00000000..b6a4ecb5
--- /dev/null
+++ b/test-generator/composer.json
@@ -0,0 +1,41 @@
+{
+    "name": "exercism/test-generator",
+    "type": "project",
+    "require": {
+        "brick/varexporter": "^0.4.0",
+        "league/flysystem": "^3.26",
+        "league/flysystem-memory": "^3.25",
+        "psr/log": "^3.0",
+        "symfony/console": "^6.0",
+        "twig/twig": "^3.8"
+    },
+    "require-dev": {
+        "doctrine/coding-standard": "^12.0",
+        "phpstan/phpstan": "^1.10",
+        "phpunit/phpunit": "^10.0",
+        "squizlabs/php_codesniffer": "^3.9"
+    },
+    "license": "MIT",
+    "autoload": {
+        "psr-4": {
+            "App\\": "src/"
+        }
+    },
+    "autoload-dev": {
+        "psr-4": {
+            "App\\Tests\\": "tests"
+        }
+    },
+    "scripts": {
+        "phpstan": "phpstan analyse src tests --configuration phpstan.neon --memory-limit=2G",
+        "test": "phpunit",
+        "lint": "phpcs",
+        "lint:fix": "phpcbf"
+    },
+    "config": {
+        "allow-plugins": {
+            "dealerdirect/phpcodesniffer-composer-installer": true
+        },
+        "sort-packages": true
+    }
+}
diff --git a/test-generator/main.php b/test-generator/main.php
new file mode 100644
index 00000000..a9a519e6
--- /dev/null
+++ b/test-generator/main.php
@@ -0,0 +1,8 @@
+<?php
+
+require __DIR__ . '/vendor/autoload.php';
+
+use App\Application;
+
+$application = new Application();
+$application->run();
diff --git a/test-generator/phpcs.xml.dist b/test-generator/phpcs.xml.dist
new file mode 100644
index 00000000..8a22e1cb
--- /dev/null
+++ b/test-generator/phpcs.xml.dist
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<ruleset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:noNamespaceSchemaLocation="vendor/squizlabs/php_codesniffer/phpcs.xsd">
+
+    <arg name="basepath" value="."/>
+    <arg name="cache" value=".phpcs-cache"/>
+    <arg name="colors"/>
+    <arg name="extensions" value="php"/>
+    <arg name="parallel" value="60"/>
+    <config name="installed_paths" value="vendor/doctrine/coding-standard/lib,vendor/slevomat/coding-standard"/>
+    <!-- Show progress of the run and show sniff names -->
+    <arg value="ps"/>
+
+    <!-- Include full Doctrine Coding Standard -->
+    <rule ref="Doctrine"/>
+
+    <!-- Include custom rules -->
+    <rule ref="Squiz.WhiteSpace.OperatorSpacing">
+        <properties>
+            <property name="ignoreNewlines" value="true" />
+            <property name="ignoreSpacingBeforeAssignments" value="false" />
+        </properties>
+    </rule>
+
+    <!-- Exclude some rules -->
+    <rule ref="SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed">
+        <exclude name="SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed"/>
+    </rule>
+    <rule ref="Generic.Formatting.MultipleStatementAlignment.NotSame">
+        <exclude name="Generic.Formatting.MultipleStatementAlignment.NotSame"/>
+    </rule>
+
+    <!-- Directories to be checked -->
+    <file>src/</file>
+    <file>tests/</file>
+</ruleset>
\ No newline at end of file
diff --git a/test-generator/phpstan.neon b/test-generator/phpstan.neon
new file mode 100644
index 00000000..22254bcd
--- /dev/null
+++ b/test-generator/phpstan.neon
@@ -0,0 +1,2 @@
+parameters:
+    level: max
diff --git a/test-generator/phpunit.xml.dist b/test-generator/phpunit.xml.dist
new file mode 100644
index 00000000..1444de8b
--- /dev/null
+++ b/test-generator/phpunit.xml.dist
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.1/phpunit.xsd"
+         colors="true"
+         cacheDirectory=".phpunit.cache"
+         executionOrder="random">
+  <testsuites>
+    <testsuite name="PHP Representer Test Suite">
+      <directory>./tests</directory>
+    </testsuite>
+  </testsuites>
+  <source>
+    <include>
+      <directory suffix=".php">src/</directory>
+    </include>
+  </source>
+  <coverage>
+    <report>
+      <clover outputFile="build/logs/clover.xml"/>
+      <html outputDirectory="build/coverage"/>
+      <text outputFile="php://stdout"/>
+    </report>
+  </coverage>
+</phpunit>
\ No newline at end of file
diff --git a/test-generator/src/Application.php b/test-generator/src/Application.php
new file mode 100644
index 00000000..0fc9049e
--- /dev/null
+++ b/test-generator/src/Application.php
@@ -0,0 +1,150 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App;
+
+use Brick\VarExporter\VarExporter;
+use Exception;
+use League\Flysystem\Filesystem;
+use League\Flysystem\Local\LocalFilesystemAdapter;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Logger\ConsoleLogger;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\SingleCommandApplication;
+use Twig\Environment;
+use Twig\Loader\ArrayLoader;
+use Twig\TwigFunction;
+
+use function array_filter;
+use function array_key_exists;
+use function assert;
+use function file_get_contents;
+use function implode;
+use function is_array;
+use function is_bool;
+use function is_string;
+use function json_decode;
+use function preg_replace;
+use function str_replace;
+use function ucwords;
+
+use const JSON_THROW_ON_ERROR;
+
+class Application extends SingleCommandApplication
+{
+    public function __construct()
+    {
+        parent::__construct('Exercism PHP Test Generator');
+    }
+
+    protected function configure(): void
+    {
+        parent::configure();
+
+        $this->setVersion('1.0.0');
+        // @TODO
+        $this->addArgument('exercise-path', InputArgument::REQUIRED, 'Path of the exercise.');
+        $this->addArgument('canonical-data', InputArgument::REQUIRED, 'Path of the canonical data for the exercise. (Use `bin/configlet -verbosity info --offline`)');
+        $this->addOption('check', null, InputOption::VALUE_NONE, 'Checks whether the existing files are the same as generated one.');
+    }
+
+    protected function execute(InputInterface $input, OutputInterface $output): int
+    {
+        $exercisePath = $input->getArgument('exercise-path');
+        $canonicalPath = $input->getArgument('canonical-data');
+        $exerciseCheck = $input->getOption('check');
+        assert(is_string($exercisePath), 'exercise-path must be a string');
+        assert(is_string($canonicalPath), 'canonical-data must be a string');
+        assert(is_bool($exerciseCheck), 'check must be a bool');
+
+        $logger = new ConsoleLogger($output);
+        $logger->info('Exercise path: ' . $exercisePath);
+        $logger->info('canonical-data path: ' . $canonicalPath);
+
+        $canonicalDataJson = file_get_contents($canonicalPath);
+        if ($canonicalDataJson === false) {
+            throw new RuntimeException('Faield to fetch canonical-data.json, check you `canonical-data` argument.');
+        }
+
+        $canonicalData = json_decode($canonicalDataJson, true, flags: JSON_THROW_ON_ERROR);
+        assert(is_array($canonicalData), 'json_decode(..., true) should return an array');
+        $exerciseAdapter = new LocalFilesystemAdapter($exercisePath);
+        $exerciseFilesystem = new Filesystem($exerciseAdapter);
+
+        $success = $this->generate($exerciseFilesystem, $exerciseCheck, $canonicalData, $logger);
+
+        return $success ? self::SUCCESS : self::FAILURE;
+    }
+
+    /** @param array<string, mixed> $canonicalData */
+    public function generate(Filesystem $exerciseDir, bool $check, array $canonicalData, LoggerInterface $logger): bool
+    {
+        // 1. Read config.json
+        $configJson = $exerciseDir->read('/.meta/config.json');
+        $config = json_decode($configJson, true, flags: JSON_THROW_ON_ERROR);
+        assert(is_array($config), 'json_decode(..., true) should return an array');
+
+        if (! isset($config['files']['test']) || ! is_array($config['files']['test'])) {
+            throw new RuntimeException('.meta/config.json: missing or invalid `files.test` key');
+        }
+
+        $testsPaths = $config['files']['test'];
+        $logger->info('.meta/config.json: tests files: ' . implode(', ', $testsPaths));
+
+        if (empty($testsPaths)) {
+            $logger->warning('.meta/config.json: `files.test` key is empty');
+        }
+
+        // 2. Read test.toml
+        $testsToml = $exerciseDir->read('/.meta/tests.toml');
+        $tests = TomlParser::parse($testsToml);
+
+        // 3. Remove `include = false` tests
+        $excludedTests = array_filter($tests, static fn (array $props) => isset($props['include']) && $props['include'] === false);
+        $this->removeExcludedTests($excludedTests, $canonicalData['cases']);
+
+        // 4. foreach tests files, check if there is a twig file
+        $twigLoader = new ArrayLoader();
+        $twigEnvironment = new Environment($twigLoader, ['strict_variables' => true, 'autoescape' => false]);
+        $twigEnvironment->addFunction(new TwigFunction('export', static fn (mixed $value) => VarExporter::export($value, VarExporter::INLINE_ARRAY)));
+        $twigEnvironment->addFunction(new TwigFunction('testfn', static fn (string $label) => 'test' . str_replace(' ', '', ucwords(preg_replace('/[^a-zA-Z0-9]/', ' ', $label)))));
+        foreach ($testsPaths as $testPath) {
+            // 5. generate the file
+            $twigFilename = $testPath . '.twig';
+            // @TODO warning or error if it does not exist
+            $testTemplate = $exerciseDir->read($twigFilename);
+            $rendered = $twigEnvironment->createTemplate($testTemplate, $twigFilename)->render($canonicalData);
+
+            if ($check) {
+                // 6. Compare it if check mode
+                if ($exerciseDir->read($testPath) !== $rendered) {
+                    // return false;
+                    throw new Exception('Differences between generated and existing file');
+                }
+            } else {
+                $exerciseDir->write($testPath, $rendered);
+            }
+        }
+
+        return true;
+    }
+
+    private function removeExcludedTests(array $tests, array &$cases): void
+    {
+        foreach ($cases as $key => &$case) {
+            if (array_key_exists('cases', $case)) {
+                $this->removeExcludedTests($tests, $case['cases']);
+            } else {
+                assert(array_key_exists('uuid', $case));
+                if (array_key_exists($case['uuid'], $tests)) {
+                    unset($cases[$key]);
+                }
+            }
+        }
+    }
+}
diff --git a/test-generator/src/TomlParser.php b/test-generator/src/TomlParser.php
new file mode 100644
index 00000000..1d88fc55
--- /dev/null
+++ b/test-generator/src/TomlParser.php
@@ -0,0 +1,79 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App;
+
+use function explode;
+use function intval;
+use function is_numeric;
+use function str_ends_with;
+use function str_starts_with;
+use function strtolower;
+use function substr;
+use function trim;
+
+/**
+ * A really basic TOML parser that handles enough of the syntax used by Exercism
+ *
+ * @see https://toml.io/en/v1.0.0
+ */
+class TomlParser
+{
+    public static function parse(string $tomlString): array
+    {
+        $lines = explode("\n", $tomlString);
+        $data = [];
+        $currentTable = null;
+
+        foreach ($lines as $line) {
+            $line = trim($line);
+
+            // Skip empty lines and comments
+            if (empty($line) || $line[0] === '#') {
+                continue;
+            }
+
+            // Check for table declaration
+            if (str_starts_with($line, '[')) {
+                $tableName = trim(substr($line, 1, -1));
+                if (! isset($data[$tableName])) {
+                    $data[$tableName] = [];
+                }
+
+                $currentTable = &$data[$tableName];
+                continue;
+            }
+
+            // @TODO Handle quoted keys, handle doted keys
+            // Parse key-value pair
+            [$key, $value] = explode('=', $line, 2);
+            $key = trim($key);
+            $value = trim($value);
+
+            // @TODO: Handle multi-line string, literal string and multi-line literal string
+            if (str_starts_with($value, '"') && str_ends_with($value, '"')) {
+                // Handle quoted strings
+                $value = substr($value, 1, -1);
+            } elseif (is_numeric($value)) {
+                // Handle integer
+                $value = intval($value);
+            } elseif (strtolower($value) === 'true') {
+                // Handle boolean true
+                $value = true;
+            } elseif (strtolower($value) === 'false') {
+                // Handle boolean false
+                $value = false;
+            }
+
+            // Assign value to current table or root data
+            if ($currentTable !== null) {
+                $currentTable[$key] = $value;
+            } else {
+                $data[$key] = $value;
+            }
+        }
+
+        return $data;
+    }
+}
diff --git a/test-generator/tests/ApplicationTest.php b/test-generator/tests/ApplicationTest.php
new file mode 100644
index 00000000..0eb4fe03
--- /dev/null
+++ b/test-generator/tests/ApplicationTest.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests;
+
+use App\Application;
+use League\Flysystem\Filesystem;
+use League\Flysystem\InMemory\InMemoryFilesystemAdapter;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\NullLogger;
+
+class ApplicationTest extends TestCase
+{
+    /**
+     * @TODO Correct integration test
+     */
+    public function testGenerate(): void
+    {
+        $exercise = new InMemoryFilesystemAdapter();
+        $exerciseFs = new Filesystem($exercise);
+        $exerciseFs->write('.meta/config.json', '{"files":{"test":["test.php"]}}');
+        $exerciseFs->write('.meta/tests.toml', '');
+        $exerciseFs->write('test.php.twig', '<?php $a = {{ export(a) }}; $b = "{{ testfn(l) }}";');
+        $canonicalData = ['a' => [1, 2], 'l' => 'this-Is_a test fn', 'cases' => []];
+
+        $application = new Application();
+        $success = $application->generate($exerciseFs, false, $canonicalData, new NullLogger());
+
+        $this->assertTrue($success);
+        $this->assertSame('<?php $a = [1, 2]; $b = "testThisIsATestFn";', $exerciseFs->read('/test.php'));
+    }
+}