diff --git a/bin/scaffold b/bin/scaffolder
similarity index 63%
rename from bin/scaffold
rename to bin/scaffolder
index 5803dd1cde1ae7183c636bc3dccd2e11cf0420c5..211b7a5869e2bd19c2c793826c1eb6cb5f08160a 100755
--- a/bin/scaffold
+++ b/bin/scaffolder
@@ -3,7 +3,8 @@
 
 declare(strict_types = 1);
 
-use Grifart\ClassScaffolder\Console\GenerateClassCommand;
+use Grifart\ClassScaffolder\Console\CheckCommand;
+use Grifart\ClassScaffolder\Console\ScaffoldCommand;
 use Symfony\Component\Console\Application;
 
 $autoloadPaths = [
@@ -24,7 +25,8 @@ if ( ! $autoloadFound) {
 	throw new RuntimeException('Unable to find "vendor/autoload.php"');
 }
 
-$application = new Application('class-scaffolder');
-$application->add($command = new GenerateClassCommand());
-$application->setDefaultCommand($command->getName(), true);
+$application = new Application('grifart/scaffolder');
+$application->add($scaffoldCommand = new ScaffoldCommand());
+$application->add(new CheckCommand());
+$application->setDefaultCommand($scaffoldCommand->getName(), false);
 exit($application->run());
diff --git a/composer.json b/composer.json
index 97da51c4ae85fa031810aca4247222465634cc11..adaf098873f8ab0d2d8cd0da0ac4eae3045ed02e 100644
--- a/composer.json
+++ b/composer.json
@@ -44,7 +44,7 @@
 		"nikic/php-parser": "To able to use KeepMethodDecorator."
 	},
 	"bin": [
-		"bin/scaffold"
+		"bin/scaffolder"
 	],
 	"autoload": {
 		"psr-4": {
diff --git a/readme.md b/readme.md
index 6d052606ddcd623e24ada5b6060b1f9ca0cf1d22..8044deeec9494f6aa060119d278ec0cbe31f94e7 100644
--- a/readme.md
+++ b/readme.md
@@ -47,16 +47,16 @@ composer require grifart/scaffolder
   The recommended way is to run the pre-packaged Composer binary:
 
   ```sh
-  composer run scaffold .definition.php
+  composer exec scaffolder scaffold .definition.php
   ```
 
 <details>
   <summary>Alternative way: Register scaffolder as a Symfony command into you app.</summary>
 
-  Alternatively, you can register the `Grifart\ClassScaffolder\Console\GenerateClassCommand` into your application's DI container and run scaffolder through *symfony/console*. This makes it easier to access your project's services and environment in definition files. *This is considered advanced usage and is not necessary in most cases.*
+  Alternatively, you can register the `Grifart\ClassScaffolder\Console\ScaffoldCommand` into your application's DI container and run scaffolder through *symfony/console*. This makes it easier to access your project's services and environment in definition files. *This is considered advanced usage and is not necessary in most cases.*
   
   ```sh
-  php bin/console grifart:scaffold .definition.php
+  php bin/console grifart:scaffolder:scaffold .definition.php
   ```
 </details>
 
@@ -122,6 +122,8 @@ composer require grifart/scaffolder
 
 5. **Use static analysis tool** such as PHPStan or Psalm to make sure that everything still works fine if you've changed any definition file.
 
+6. **Make sure that you haven't accidentally changed any generated file** by adding `composer exec scaffolder check .definition.php` to your CI workflow. The command fails if any generated class differs from its definition, and thus running the scaffolder would result in losing your changes.
+
 
 ## Definition files
 
diff --git a/src/Console/CheckCommand.php b/src/Console/CheckCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..94ba8f454bf3202416ace12f84c70299581f2290
--- /dev/null
+++ b/src/Console/CheckCommand.php
@@ -0,0 +1,93 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Grifart\ClassScaffolder\Console;
+
+use Grifart\ClassScaffolder\Definition\ClassDefinition;
+use Grifart\ClassScaffolder\DefinitionFile;
+use Grifart\ClassScaffolder\DefinitionResult;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+
+final class CheckCommand extends ScaffolderCommand
+{
+	protected function configure(): void
+	{
+		parent::configure();
+
+		$this->setName('check')
+			->setDescription('Checks that all generated classes match given definitions.')
+			->setAliases(['grifart:scaffolder:check']);
+	}
+
+	protected function execute(InputInterface $input, OutputInterface $output): int
+	{
+		try {
+			$definitionFiles = $this->locateDefinitionFiles($input);
+		} catch (\Throwable $error) {
+			$this->printError($error, $output);
+			return 1;
+		}
+
+		$results = [];
+		$isSuccess = true;
+
+		$processedFiles = 0;
+		$total = \count($definitionFiles);
+
+		$output->writeln(\sprintf('Checking %d definition file%s:%s', $total, $total !== 1 ? 's' : '', \PHP_EOL));
+
+		foreach ($definitionFiles as $definitionFile) {
+			$results[] = $result = $this->processFile($definitionFile, $input);
+
+			if ($result->isSuccessful()) {
+				$output->write('.');
+			} else {
+				$output->write('<error>F</error>');
+				$isSuccess = false;
+			}
+
+			if (++$processedFiles % 40 === 0) {
+				$output->writeln('');
+			}
+		}
+
+		$output->writeln(\PHP_EOL);
+
+		$this->printResults($results, $output);
+
+		return (int) ! $isSuccess;
+	}
+
+	protected function processDefinition(
+		ClassDefinition $definition,
+		DefinitionFile $definitionFile,
+		InputInterface $input,
+	): DefinitionResult
+	{
+		try {
+			$generatedFile = $this->classGenerator->generateClass($definition);
+		} catch (\Throwable $error) {
+			return DefinitionResult::error($definition, $error);
+		}
+
+		$code = (string) $generatedFile;
+		$targetPath = $definitionFile->resolveTargetFileFor($definition);
+
+		if ( ! \file_exists($targetPath)) {
+			return DefinitionResult::error($definition, new \RuntimeException('There is no generated file for given definition.'));
+		}
+
+		$contents = \file_get_contents($targetPath);
+		if ($contents === false) {
+			return DefinitionResult::error($definition, new \RuntimeException('Failed to read file.'));
+		}
+
+		if ($contents !== $code) {
+			return DefinitionResult::error($definition, new \RuntimeException('The generated file contains changes that will be lost if you generate it again.'));
+		}
+
+		return DefinitionResult::success($definition);
+	}
+}
diff --git a/src/Console/GenerateClassCommand.php b/src/Console/GenerateClassCommand.php
deleted file mode 100644
index 0d0628c6d0e9fa9bd25f08f8ddb8c9e6a4474d0c..0000000000000000000000000000000000000000
--- a/src/Console/GenerateClassCommand.php
+++ /dev/null
@@ -1,401 +0,0 @@
-<?php
-
-declare(strict_types = 1);
-
-namespace Grifart\ClassScaffolder\Console;
-
-use Grifart\ClassScaffolder\ClassGenerator;
-use Grifart\ClassScaffolder\Definition\ClassDefinition;
-use Nette\Utils\Finder;
-use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputArgument;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Input\InputOption;
-use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\Console\Style\SymfonyStyle;
-use Webmozart\PathUtil\Path;
-
-
-final class GenerateClassCommand extends Command
-{
-	private ClassGenerator $classGenerator;
-	private SymfonyStyle $style;
-
-	public function __construct()
-	{
-		parent::__construct();
-		$this->classGenerator = new ClassGenerator();
-	}
-
-	protected function configure(): void
-	{
-		$this->setName('grifart:scaffold')
-			->setDescription('Generate classes from given definitions.')
-			->addArgument('definition', InputArgument::OPTIONAL, 'Definition file or directory containing definitions.', \getcwd())
-			->addOption('search-pattern', NULL, InputArgument::OPTIONAL, 'Search pattern for your definition files.', '*.definition.php')
-			->addOption('no-readonly', NULL, InputOption::VALUE_NONE, 'Generated files are marked as read only by default (using chmod). Use this option to turn off this behaviour.')
-			->addOption('dry-run', NULL, InputOption::VALUE_NONE, 'Only print the generated file to output instead of saving it.')
-			->setAliases(['grifart:scaffolder:generateClass']);
-	}
-
-	protected function execute(InputInterface $input, OutputInterface $output): int
-	{
-		$this->style = new SymfonyStyle($input, $output);
-
-		$definitionPath = $input->getArgument('definition');
-		$cwd = \getcwd();
-		\assert(\is_string($definitionPath) && \is_string($cwd));
-		$definitionPath = Path::makeAbsolute($definitionPath, $cwd);
-
-		$searchPattern = $input->getOption('search-pattern');
-		\assert(\is_string($searchPattern));
-
-		// 1. find files to process
-		$filesToProcess = [];
-		if (is_dir($definitionPath)) {
-			foreach(Finder::find($searchPattern)->from($definitionPath) as $definitionFile) {
-				$filesToProcess[] = (string) $definitionFile;
-			}
-		} elseif (is_file($definitionPath)) {
-			$filesToProcess[] = $definitionPath;
-		} else {
-			$output->writeln('<error>Given path is nor a file or directory.</error>');
-			return 1;
-		}
-
-		// 2. process them
-
-		/** @var FileResult[] */
-		$results = [];
-		$isSuccess = true;
-
-		$processedFiles = 0;
-		$total = count($filesToProcess);
-
-		$output->writeln(\sprintf('Processing %d definition file%s:%s', $total, $total !== 1 ? 's' : '', \PHP_EOL));
-
-		foreach ($filesToProcess as $fileToProcess) {
-			$results[] = $result = $this->processFile($fileToProcess, $input);
-
-			if ($result->isSuccessful()) {
-				$output->write('.');
-			} else {
-				$output->write('<error>F</error>');
-				$isSuccess = false;
-			}
-
-			if (++$processedFiles % 40 === 0) {
-				$output->writeln('');
-			}
-		}
-
-		$output->writeln(\PHP_EOL);
-
-		$this->printResults($results, $input, $output);
-
-		return (int) ! $isSuccess;
-	}
-
-	private function processFile(
-		string $definitionFile,
-		InputInterface $input,
-	): FileResult
-	{
-		try {
-			$definitions = $this->loadDefinitions($definitionFile);
-		} catch (\Throwable $error) {
-			return new FileResult($definitionFile, $error);
-		}
-
-		$result = new FileResult($definitionFile, null);
-		foreach ($definitions as $definition) {
-			$definitionResult = $this->generateClass($definition, $definitionFile, $input);
-			$result->addDefinition($definitionResult);
-		}
-
-		return $result;
-	}
-
-	private function generateClass(
-		ClassDefinition $definition,
-		string $definitionFile,
-		InputInterface $input,
-	): DefinitionResult
-	{
-		try {
-			$generatedFile = $this->classGenerator->generateClass($definition);
-		} catch (\Throwable $error) {
-			return DefinitionResult::error($definition, $error);
-		}
-
-		$code = (string) $generatedFile;
-
-		$targetPath = Path::join(
-			Path::getDirectory($definitionFile),
-			$definition->getClassName() . '.php'
-		);
-
-		if ($input->getOption('dry-run')) {
-			return DefinitionResult::success($definition, $code);
-		}
-
-		if (\file_exists($targetPath)) {
-			\chmod($targetPath, 0664); // some users accessing files using group permissions
-		}
-
-		if (\file_put_contents($targetPath, $code) === false) {
-			throw new \RuntimeException('Failed to write file.');
-		}
-
-		if ( ! $input->getOption('no-readonly')) {
-			\chmod($targetPath, 0444); // read-only -- assumes single user system
-		}
-
-		return DefinitionResult::success($definition, $code);
-	}
-
-	/**
-	 * @return ClassDefinition[]
-	 */
-	private function loadDefinitions(string $definitionFile): iterable
-	{
-		$definitionFile = Path::canonicalize($definitionFile);
-		if ( ! \file_exists($definitionFile)) {
-			throw new \InvalidArgumentException(\sprintf(
-				'Definition file not found at %s',
-				$definitionFile
-			));
-		}
-
-		$definitions = require $definitionFile;
-		if ($definitions instanceof ClassDefinition) {
-			$definitions = [$definitions];
-		}
-
-		if ( ! \is_iterable($definitions)) {
-			throw new \InvalidArgumentException(\sprintf(
-				'Definition file must return an iterable of ClassDefinition, %s received.',
-				\get_debug_type($definitions),
-			));
-		}
-
-		$count = 0;
-		foreach ($definitions as $definition) {
-			if ( ! ($definition instanceof ClassDefinition)) {
-				throw new \InvalidArgumentException(\sprintf(
-					'Definition file must return instanceof ClassDefinition, %s received.',
-					\get_debug_type($definition),
-				));
-			}
-
-			$count++;
-		}
-
-		if ($count === 0) {
-			throw new \InvalidArgumentException('Definition file must return at least one ClassDefinition, empty list received.');
-		}
-
-		return $definitions;
-	}
-
-	/**
-	 * @param FileResult[] $results
-	 */
-	private function printResults(
-		array $results,
-		InputInterface $input,
-		OutputInterface $output,
-	): void
-	{
-		$isDryRun = (bool) $input->getOption('dry-run');
-		foreach ($results as $result) {
-			if ( ! $isDryRun && $result->isSuccessful() && ! $output->isVerbose()) {
-				continue;
-			}
-
-			$cwd = \getcwd();
-			\assert(\is_string($cwd));
-			$definitionFilePath = Path::makeRelative($result->getDefinitionFile(), $cwd);
-			$definitions = $result->getDefinitions();
-
-			$definitionFileError = $result->getError();
-			if ($definitionFileError !== null) {
-				$this->style->section(\sprintf(
-					'%s: <error>error loading definitions</error>',
-					$definitionFilePath,
-				));
-
-				$this->printError($definitionFileError, $output);
-
-				continue;
-			}
-
-			$errors = \array_filter($definitions, static fn(DefinitionResult $result) => ! $result->isSuccessful());
-			$this->style->section(\sprintf(
-				'%s: %d definition%s, %s',
-				$definitionFilePath,
-				\count($definitions),
-				\count($definitions) !== 1 ? 's' : '',
-				\count($errors) === 0 ? '<info>OK</info>' : \sprintf(
-					'<error>%d error%s</error>',
-					\count($errors),
-					\count($errors) > 1 ? 's' : '',
-				),
-			));
-
-			foreach ($definitions as $definition) {
-				if ( ! $isDryRun && $definition->isSuccessful() && ! $output->isVeryVerbose()) {
-					continue;
-				}
-
-				$output->writeln(\sprintf(
-					'%s %s%s',
-					$definition->isSuccessful() ? '<info> OK </info>' : '<error>FAIL</error>',
-					$definition->getClassName(),
-					\PHP_EOL,
-				));
-
-				if ( ! $definition->isSuccessful()) {
-					$error = $definition->getError();
-					\assert($error !== null);
-
-					$this->printError($error, $output);
-					$output->writeln('');
-
-				} elseif ($isDryRun) {
-					$code = $definition->getCode();
-					\assert($code !== null);
-
-					$output->writeln('');
-					$output->writeln($code . \PHP_EOL);
-				}
-			}
-		}
-	}
-
-	private function printError(\Throwable $error, OutputInterface $output): void
-	{
-		$this->style->error([
-			\sprintf(
-				'%s: %s',
-				\get_class($error),
-				$error->getMessage(),
-			),
-			\sprintf(
-				'in %s:%d',
-				$error->getFile(),
-				$error->getLine(),
-			),
-		]);
-
-		if ( ! \class_exists(\Tracy\Debugger::class)) {
-			return;
-		}
-
-		try {
-			$exceptionFile = \Tracy\Debugger::log($error);
-		} catch (\Throwable) {
-			return;
-		}
-
-		if (\is_string($exceptionFile)) {
-			$cwd = \getcwd();
-			\assert(\is_string($cwd));
-
-			$output->writeln(\sprintf(
-				'Error was logged in %s',
-				Path::makeRelative($exceptionFile, $cwd),
-			));
-		}
-	}
-}
-
-/**
- * @internal
- */
-final class FileResult
-{
-	/** @var DefinitionResult[] */
-	private array $definitions = [];
-
-	public function __construct(
-		private string $definitionFile,
-		private \Throwable|null $error,
-	) {}
-
-	public function getDefinitionFile(): string
-	{
-		return $this->definitionFile;
-	}
-
-	public function getError(): \Throwable|null
-	{
-		return $this->error;
-	}
-
-	public function isSuccessful(): bool
-	{
-		foreach ($this->definitions as $result) {
-			if ( ! $result->isSuccessful()) {
-				return false;
-			}
-		}
-
-		return $this->error === null;
-	}
-
-	public function addDefinition(DefinitionResult $definitionResult): void
-	{
-		$this->definitions[] = $definitionResult;
-	}
-
-	/**
-	 * @return DefinitionResult[]
-	 */
-	public function getDefinitions(): array
-	{
-		return $this->definitions;
-	}
-}
-
-/**
- * @internal
- */
-final class DefinitionResult
-{
-	private function __construct(
-		private ClassDefinition $definition,
-		private \Throwable|null $error,
-		private string|null $code,
-	) {}
-
-	public static function success(ClassDefinition $definition, string $code): self
-	{
-		return new self($definition, null, $code);
-	}
-
-	public static function error(ClassDefinition $definition, \Throwable $error): self
-	{
-		return new self($definition, $error, null);
-	}
-
-	public function getClassName(): string
-	{
-		return $this->definition->getFullyQualifiedName();
-	}
-
-	public function isSuccessful(): bool
-	{
-		return $this->error === null;
-	}
-
-	public function getError(): \Throwable|null
-	{
-		return $this->error;
-	}
-
-	public function getCode(): string|null
-	{
-		return $this->code;
-	}
-}
diff --git a/src/Console/ScaffoldCommand.php b/src/Console/ScaffoldCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..207a1f6b07bb9c93b3f1637993db5651a21cbb4d
--- /dev/null
+++ b/src/Console/ScaffoldCommand.php
@@ -0,0 +1,96 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Grifart\ClassScaffolder\Console;
+
+use Grifart\ClassScaffolder\Definition\ClassDefinition;
+use Grifart\ClassScaffolder\DefinitionFile;
+use Grifart\ClassScaffolder\DefinitionResult;
+use Grifart\ClassScaffolder\FileResult;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+final class ScaffoldCommand extends ScaffolderCommand
+{
+	protected function configure(): void
+	{
+		parent::configure();
+
+		$this->setName('scaffold')
+			->setDescription('Generate classes from given definitions.')
+			->addOption('no-readonly', NULL, InputOption::VALUE_NONE, 'Generated files are marked as read only by default (using chmod). Use this option to turn off this behaviour.')
+			->setAliases(['grifart:scaffolder:scaffold', 'grifart:scaffolder:generateClass']);
+	}
+
+	protected function execute(InputInterface $input, OutputInterface $output): int
+	{
+		try {
+			$definitionFiles = $this->locateDefinitionFiles($input);
+		} catch (\Throwable $error) {
+			$this->printError($error, $output);
+			return 1;
+		}
+
+		/** @var FileResult[] */
+		$results = [];
+		$isSuccess = true;
+
+		$processedFiles = 0;
+		$total = count($definitionFiles);
+
+		$output->writeln(\sprintf('Processing %d definition file%s:%s', $total, $total !== 1 ? 's' : '', \PHP_EOL));
+
+		foreach ($definitionFiles as $definitionFile) {
+			$results[] = $result = $this->processFile($definitionFile, $input);
+
+			if ($result->isSuccessful()) {
+				$output->write('.');
+			} else {
+				$output->write('<error>F</error>');
+				$isSuccess = false;
+			}
+
+			if (++$processedFiles % 40 === 0) {
+				$output->writeln('');
+			}
+		}
+
+		$output->writeln(\PHP_EOL);
+
+		$this->printResults($results, $output);
+
+		return (int) ! $isSuccess;
+	}
+
+	protected function processDefinition(
+		ClassDefinition $definition,
+		DefinitionFile $definitionFile,
+		InputInterface $input,
+	): DefinitionResult
+	{
+		try {
+			$generatedFile = $this->classGenerator->generateClass($definition);
+		} catch (\Throwable $error) {
+			return DefinitionResult::error($definition, $error);
+		}
+
+		$code = (string) $generatedFile;
+		$targetPath = $definitionFile->resolveTargetFileFor($definition);
+
+		if (\file_exists($targetPath)) {
+			\chmod($targetPath, 0664); // some users accessing files using group permissions
+		}
+
+		if (\file_put_contents($targetPath, $code) === false) {
+			return DefinitionResult::error($definition, new \RuntimeException('Failed to write file.'));
+		}
+
+		if ( ! $input->getOption('no-readonly')) {
+			\chmod($targetPath, 0444); // read-only -- assumes single user system
+		}
+
+		return DefinitionResult::success($definition);
+	}
+}
diff --git a/src/Console/ScaffolderCommand.php b/src/Console/ScaffolderCommand.php
new file mode 100644
index 0000000000000000000000000000000000000000..38fcca146694d5717c755716c337f438889f8e3f
--- /dev/null
+++ b/src/Console/ScaffolderCommand.php
@@ -0,0 +1,200 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Grifart\ClassScaffolder\Console;
+
+use Grifart\ClassScaffolder\ClassGenerator;
+use Grifart\ClassScaffolder\Definition\ClassDefinition;
+use Grifart\ClassScaffolder\DefinitionFile;
+use Grifart\ClassScaffolder\DefinitionFilesLocator;
+use Grifart\ClassScaffolder\DefinitionResult;
+use Grifart\ClassScaffolder\FileProcessor;
+use Grifart\ClassScaffolder\FileResult;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+use Webmozart\PathUtil\Path;
+
+abstract class ScaffolderCommand extends Command
+{
+	private const ARGUMENT_DEFINITION_PATH = 'definition';
+	private const OPTION_SEARCH_PATTERN = 'search-pattern';
+
+	protected ClassGenerator $classGenerator;
+	private DefinitionFilesLocator $definitionFilesLocator;
+	private FileProcessor $fileProcessor;
+	protected SymfonyStyle $style;
+
+	public function __construct()
+	{
+		parent::__construct();
+		$this->classGenerator = new ClassGenerator();
+		$this->definitionFilesLocator = new DefinitionFilesLocator();
+		$this->fileProcessor = new FileProcessor();
+	}
+
+	protected function initialize(InputInterface $input, OutputInterface $output): void
+	{
+		$this->style = new SymfonyStyle($input, $output);
+	}
+
+	protected function configure(): void
+	{
+		$this->addArgument(self::ARGUMENT_DEFINITION_PATH, InputArgument::OPTIONAL, 'Definition file or directory containing definitions.', \getcwd());
+		$this->addOption(self::OPTION_SEARCH_PATTERN, null, InputOption::VALUE_REQUIRED, 'Search pattern for your definition files.', '*.definition.php');
+	}
+
+	/**
+	 * @return DefinitionFile[]
+	 */
+	protected function locateDefinitionFiles(
+		InputInterface $input,
+	): array
+	{
+		$path = $this->getDefinitionPath($input);
+		$searchPattern = $this->getSearchPattern($input);
+
+		return $this->definitionFilesLocator->locateDefinitionFiles($path, $searchPattern);
+	}
+
+	protected function processFile(
+		DefinitionFile $definitionFile,
+		InputInterface $input,
+	): FileResult
+	{
+		return $this->fileProcessor->processFile(
+			$definitionFile,
+			fn(ClassDefinition $definition) => $this->processDefinition($definition, $definitionFile, $input),
+		);
+	}
+
+	abstract protected function processDefinition(
+		ClassDefinition $definition,
+		DefinitionFile $definitionFile,
+		InputInterface $input,
+	): DefinitionResult;
+
+	protected function printError(\Throwable $error, OutputInterface $output): void
+	{
+		$this->style->error([
+			\sprintf(
+				'%s: %s',
+				\get_class($error),
+				$error->getMessage(),
+			),
+			\sprintf(
+				'in %s:%d',
+				$error->getFile(),
+				$error->getLine(),
+			),
+		]);
+
+		if ( ! \class_exists(\Tracy\Debugger::class)) {
+			return;
+		}
+
+		try {
+			$exceptionFile = \Tracy\Debugger::log($error);
+		} catch (\Throwable) {
+			return;
+		}
+
+		if (\is_string($exceptionFile)) {
+			$cwd = \getcwd();
+			\assert(\is_string($cwd));
+
+			$output->writeln(\sprintf(
+				'Error was logged in %s',
+				Path::makeRelative($exceptionFile, $cwd),
+			));
+		}
+	}
+
+	/**
+	 * @param FileResult[] $results
+	 */
+	protected function printResults(
+		array $results,
+		OutputInterface $output,
+	): void
+	{
+		foreach ($results as $result) {
+			if ($result->isSuccessful() && ! $output->isVerbose()) {
+				continue;
+			}
+
+			$cwd = \getcwd();
+			\assert(\is_string($cwd));
+			$definitionFilePath = Path::makeRelative($result->getDefinitionFile(), $cwd);
+			$definitions = $result->getDefinitions();
+
+			$definitionFileError = $result->getError();
+			if ($definitionFileError !== null) {
+				$this->style->section(\sprintf(
+					'%s: <error>error loading definitions</error>',
+					$definitionFilePath,
+				));
+
+				$this->printError($definitionFileError, $output);
+
+				continue;
+			}
+
+			$errors = \array_filter($definitions, static fn(DefinitionResult $result) => ! $result->isSuccessful());
+			$this->style->section(\sprintf(
+				'%s: %d definition%s, %s',
+				$definitionFilePath,
+				\count($definitions),
+				\count($definitions) !== 1 ? 's' : '',
+				\count($errors) === 0 ? '<info>OK</info>' : \sprintf(
+					'<error>%d error%s</error>',
+					\count($errors),
+					\count($errors) > 1 ? 's' : '',
+				),
+			));
+
+			foreach ($definitions as $definition) {
+				if ($definition->isSuccessful() && ! $output->isVeryVerbose()) {
+					continue;
+				}
+
+				$output->writeln(\sprintf(
+					'%s %s%s',
+					$definition->isSuccessful() ? '<info> OK </info>' : '<error>FAIL</error>',
+					$definition->getClassName(),
+					\PHP_EOL,
+				));
+
+				if ( ! $definition->isSuccessful()) {
+					$error = $definition->getError();
+					\assert($error !== null);
+
+					$this->printError($error, $output);
+					$output->writeln('');
+				}
+			}
+		}
+	}
+
+	private function getDefinitionPath(InputInterface $input): string
+	{
+		$cwd = \getcwd();
+
+		$definitionPath = $input->getArgument(self::ARGUMENT_DEFINITION_PATH);
+		\assert(\is_string($definitionPath) && \is_string($cwd));
+
+		return Path::makeAbsolute($definitionPath, $cwd);
+	}
+
+	private function getSearchPattern(InputInterface $input): string
+	{
+		$searchPattern = $input->getOption(self::OPTION_SEARCH_PATTERN);
+		\assert(\is_string($searchPattern));
+
+		return $searchPattern;
+	}
+}
diff --git a/src/DefinitionFile.php b/src/DefinitionFile.php
new file mode 100644
index 0000000000000000000000000000000000000000..133be9f20c0d32b672edac79b0863ea4899ba227
--- /dev/null
+++ b/src/DefinitionFile.php
@@ -0,0 +1,81 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Grifart\ClassScaffolder;
+
+use Grifart\ClassScaffolder\Definition\ClassDefinition;
+use Webmozart\PathUtil\Path;
+
+/**
+ * @internal
+ */
+final class DefinitionFile
+{
+	private function __construct(
+		private string $path,
+	) {}
+
+	public static function from(string $path): self
+	{
+		return new self(
+			Path::canonicalize($path),
+		);
+	}
+
+	public function getPath(): string
+	{
+		return $this->path;
+	}
+
+	/**
+	 * @return iterable<ClassDefinition>
+	 */
+	public function load(): iterable
+	{
+		if ( ! \file_exists($this->path) || ! \is_readable($this->path)) {
+			throw new \RuntimeException(\sprintf(
+				'Definition file not found or not readable at %s',
+				$this->path,
+			));
+		}
+
+		$definitions = require $this->path;
+		if ($definitions instanceof ClassDefinition) {
+			$definitions = [$definitions];
+		}
+
+		if ( ! \is_iterable($definitions)) {
+			throw new \RuntimeException(\sprintf(
+				'Definition file must return an iterable of ClassDefinition, %s received.',
+				\get_debug_type($definitions),
+			));
+		}
+
+		$count = 0;
+		foreach ($definitions as $definition) {
+			if ( ! ($definition instanceof ClassDefinition)) {
+				throw new \RuntimeException(\sprintf(
+					'Definition file must return instanceof ClassDefinition, %s received.',
+					\get_debug_type($definition),
+				));
+			}
+
+			$count++;
+		}
+
+		if ($count === 0) {
+			throw new \RuntimeException('Definition file must return at least one ClassDefinition, empty list received.');
+		}
+
+		return $definitions;
+	}
+
+	public function resolveTargetFileFor(ClassDefinition $definition): string
+	{
+		return Path::join(
+			Path::getDirectory($this->path),
+			$definition->getClassName() . '.php',
+		);
+	}
+}
diff --git a/src/DefinitionFilesLocator.php b/src/DefinitionFilesLocator.php
new file mode 100644
index 0000000000000000000000000000000000000000..36f209966e19c729ec6f5420a0072888c61f0e57
--- /dev/null
+++ b/src/DefinitionFilesLocator.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Grifart\ClassScaffolder;
+
+use Nette\Utils\Finder;
+
+final class DefinitionFilesLocator
+{
+	/**
+	 * @return DefinitionFile[]
+	 */
+	public function locateDefinitionFiles(
+		string $path,
+		string $searchPattern,
+	): array
+	{
+		$result = [];
+
+		if (\is_dir($path)) {
+			$files = Finder::findFiles($searchPattern)->from($path);
+			foreach ($files as $file) {
+				$result[] = DefinitionFile::from($file->getPathname());
+			}
+		} elseif (\is_file($path)) {
+			$result[] = DefinitionFile::from($path);
+		} else {
+			throw new \RuntimeException('Given path is neither a file nor a directory.');
+		}
+
+		return $result;
+	}
+}
diff --git a/src/DefinitionResult.php b/src/DefinitionResult.php
new file mode 100644
index 0000000000000000000000000000000000000000..0394dc7bfa655b7708afbb0734dcececf4e315bb
--- /dev/null
+++ b/src/DefinitionResult.php
@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Grifart\ClassScaffolder;
+
+use Grifart\ClassScaffolder\Definition\ClassDefinition;
+
+/**
+ * @internal
+ */
+final class DefinitionResult
+{
+	private function __construct(
+		private ClassDefinition $definition,
+		private \Throwable|null $error,
+	) {}
+
+	public static function success(ClassDefinition $definition): self
+	{
+		return new self($definition, null);
+	}
+
+	public static function error(ClassDefinition $definition, \Throwable $error): self
+	{
+		return new self($definition, $error);
+	}
+
+	public function getClassName(): string
+	{
+		return $this->definition->getFullyQualifiedName();
+	}
+
+	public function isSuccessful(): bool
+	{
+		return $this->error === null;
+	}
+
+	public function getError(): \Throwable|null
+	{
+		return $this->error;
+	}
+}
diff --git a/src/FileProcessor.php b/src/FileProcessor.php
new file mode 100644
index 0000000000000000000000000000000000000000..bb8393894f5c98336a6b28068d49c3816285dc2d
--- /dev/null
+++ b/src/FileProcessor.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Grifart\ClassScaffolder;
+
+final class FileProcessor
+{
+	/**
+	 * @param (\Closure(Definition\ClassDefinition): DefinitionResult) $processDefinition
+	 */
+	public function processFile(
+		DefinitionFile $definitionFile,
+		\Closure $processDefinition,
+	): FileResult
+	{
+		try {
+			$definitions = $definitionFile->load();
+		} catch (\Throwable $error) {
+			return new FileResult($definitionFile, $error);
+		}
+
+		$result = new FileResult($definitionFile, null);
+		foreach ($definitions as $definition) {
+			try {
+				$definitionResult = $processDefinition($definition);
+				$result->addDefinition($definitionResult);
+			} catch (\Throwable $error) {
+				$result->addDefinition(DefinitionResult::error($definition, $error));
+			}
+		}
+
+		return $result;
+	}
+}
diff --git a/src/FileResult.php b/src/FileResult.php
new file mode 100644
index 0000000000000000000000000000000000000000..b23616f82fd7bb837081c1298fc6c6e88653f0e8
--- /dev/null
+++ b/src/FileResult.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Grifart\ClassScaffolder;
+
+/**
+ * @internal
+ */
+final class FileResult
+{
+	/** @var DefinitionResult[] */
+	private array $definitions = [];
+
+	public function __construct(
+		private DefinitionFile $definitionFile,
+		private \Throwable|null $error,
+	) {}
+
+	public function getDefinitionFile(): string
+	{
+		return $this->definitionFile->getPath();
+	}
+
+	public function getError(): \Throwable|null
+	{
+		return $this->error;
+	}
+
+	public function isSuccessful(): bool
+	{
+		foreach ($this->definitions as $result) {
+			if ( ! $result->isSuccessful()) {
+				return false;
+			}
+		}
+
+		return $this->error === null;
+	}
+
+	public function addDefinition(DefinitionResult $definitionResult): void
+	{
+		$this->definitions[] = $definitionResult;
+	}
+
+	/**
+	 * @return DefinitionResult[]
+	 */
+	public function getDefinitions(): array
+	{
+		return $this->definitions;
+	}
+}
diff --git a/tests/Console/CheckCommandTest.expected.txt b/tests/Console/CheckCommandTest.expected.txt
new file mode 100644
index 0000000000000000000000000000000000000000..678ab13067df1197b22fb993f4afb7b4804b467a
--- /dev/null
+++ b/tests/Console/CheckCommandTest.expected.txt
@@ -0,0 +1,21 @@
+Checking 1 definition file:
+
+F
+
+
+check/.definition.php: 3 definitions, 2 errors
+----------------------------------------------
+
+FAIL ModifiedClass
+
+ [ERROR] RuntimeException: The generated file contains changes that will be lost
+         if you generate it again.
+
+         in %A%
+
+
+FAIL MissingClass
+
+ [ERROR] RuntimeException: There is no generated file for given definition.
+
+         in %A%
diff --git a/tests/Console/CheckCommandTest.phpt b/tests/Console/CheckCommandTest.phpt
new file mode 100644
index 0000000000000000000000000000000000000000..808e2b3db417aa4e3b5cf09464ac0cfcbb440cdf
--- /dev/null
+++ b/tests/Console/CheckCommandTest.phpt
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Grifart\ClassScaffolder\Test\Console;
+
+use Grifart\ClassScaffolder\Console\CheckCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\ArrayInput;
+use Symfony\Component\Console\Output\BufferedOutput;
+use Tester\Assert;
+
+require __DIR__ . '/../bootstrap.php';
+
+/**
+ * @testCase
+ */
+final class CheckCommandTest extends CommandTestCase
+{
+	public function testCheck(): void
+	{
+		$input = new ArrayInput(['check', 'definition' => __DIR__ . '/check']);
+		$output = new BufferedOutput();
+
+		$exitCode = $this->runCommand($input, $output);
+
+		Assert::same(1, $exitCode);
+		Assert::matchFile(__DIR__ . '/CheckCommandTest.expected.txt', $output->fetch());
+	}
+
+	protected function createCommand(): Command
+	{
+		return new CheckCommand();
+	}
+}
+
+(new CheckCommandTest())->run();
diff --git a/tests/Console/CommandTestCase.php b/tests/Console/CommandTestCase.php
new file mode 100644
index 0000000000000000000000000000000000000000..f61d1e145f3a0e9b0670553dea810f02da8dc95d
--- /dev/null
+++ b/tests/Console/CommandTestCase.php
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Grifart\ClassScaffolder\Test\Console;
+
+use Symfony\Component\Console\Application;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Tester\TestCase;
+
+abstract class CommandTestCase extends TestCase
+{
+	abstract protected function createCommand(): Command;
+
+	protected function runCommand(
+		InputInterface $input,
+		OutputInterface $output,
+	): int
+	{
+		$command = $this->createCommand();
+
+		$application = new Application();
+		$application->setAutoExit(false);
+		$application->add($command);
+
+		return $application->run($input, $output);
+	}
+}
diff --git a/tests/Console/ScaffoldCommandTest.expected.all.txt b/tests/Console/ScaffoldCommandTest.expected.all.txt
new file mode 100644
index 0000000000000000000000000000000000000000..02976aad7874a3227b2a774de327f1fc641eb4ef
--- /dev/null
+++ b/tests/Console/ScaffoldCommandTest.expected.all.txt
@@ -0,0 +1,21 @@
+Processing 3 definition files:
+
+F.F
+
+
+definitions/invalid.definition.php: error loading definitions
+-------------------------------------------------------------
+
+ [ERROR] RuntimeException: Definition file must return an iterable of
+         ClassDefinition, int received.
+
+         in %A%
+
+definitions/failing.definition.php: 2 definitions, 1 error
+----------------------------------------------------------
+
+FAIL FailingClass
+
+ [ERROR] RuntimeException: Oh no, I failed :(
+
+         in %A%
diff --git a/tests/Console/ScaffoldCommandTest.expected.failing.txt b/tests/Console/ScaffoldCommandTest.expected.failing.txt
new file mode 100644
index 0000000000000000000000000000000000000000..bd03979bc87f3650f6e8b31b7c594ba4bc53f2a1
--- /dev/null
+++ b/tests/Console/ScaffoldCommandTest.expected.failing.txt
@@ -0,0 +1,13 @@
+Processing 1 definition file:
+
+F
+
+
+definitions/failing.definition.php: 2 definitions, 1 error
+----------------------------------------------------------
+
+FAIL FailingClass
+
+ [ERROR] RuntimeException: Oh no, I failed :(
+
+         in %A%
diff --git a/tests/Console/ScaffoldCommandTest.expected.invalid.txt b/tests/Console/ScaffoldCommandTest.expected.invalid.txt
new file mode 100644
index 0000000000000000000000000000000000000000..02d166c5adf79cba2dadf8098c1f39afca126a12
--- /dev/null
+++ b/tests/Console/ScaffoldCommandTest.expected.invalid.txt
@@ -0,0 +1,12 @@
+Processing 1 definition file:
+
+F
+
+
+definitions/invalid.definition.php: error loading definitions
+-------------------------------------------------------------
+
+ [ERROR] RuntimeException: Definition file must return an iterable of
+         ClassDefinition, int received.
+
+         in %A%
diff --git a/tests/Console/ScaffoldCommandTest.expected.success.txt b/tests/Console/ScaffoldCommandTest.expected.success.txt
new file mode 100644
index 0000000000000000000000000000000000000000..9d34f1cb278cf5e020011be8560b4c0d576a379d
--- /dev/null
+++ b/tests/Console/ScaffoldCommandTest.expected.success.txt
@@ -0,0 +1,3 @@
+Processing 1 definition file:
+
+.
diff --git a/tests/Console/ScaffoldCommandTest.expected.verbose.txt b/tests/Console/ScaffoldCommandTest.expected.verbose.txt
new file mode 100644
index 0000000000000000000000000000000000000000..17c69a83302861e05a55ed27dccabcd886b13e3b
--- /dev/null
+++ b/tests/Console/ScaffoldCommandTest.expected.verbose.txt
@@ -0,0 +1,7 @@
+Processing 1 definition file:
+
+.
+
+
+definitions/good.definition.php: 2 definitions, OK
+--------------------------------------------------
diff --git a/tests/Console/ScaffoldCommandTest.expected.veryVerbose.txt b/tests/Console/ScaffoldCommandTest.expected.veryVerbose.txt
new file mode 100644
index 0000000000000000000000000000000000000000..7b3b2e9d59fdbc6bc46289e3ed1b221f1edfb39e
--- /dev/null
+++ b/tests/Console/ScaffoldCommandTest.expected.veryVerbose.txt
@@ -0,0 +1,11 @@
+Processing 1 definition file:
+
+.
+
+
+definitions/good.definition.php: 2 definitions, OK
+--------------------------------------------------
+
+ OK  DataClass
+
+ OK  AnotherClass
diff --git a/tests/Console/ScaffoldCommandTest.phpt b/tests/Console/ScaffoldCommandTest.phpt
new file mode 100644
index 0000000000000000000000000000000000000000..2205f55efbde9fc99d10ad30075b305b9f27686c
--- /dev/null
+++ b/tests/Console/ScaffoldCommandTest.phpt
@@ -0,0 +1,82 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Grifart\ClassScaffolder\Test\Console;
+
+use Grifart\ClassScaffolder\Console\ScaffoldCommand;
+use Nette\Utils\Finder;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\ArrayInput;
+use Symfony\Component\Console\Output\BufferedOutput;
+use Tester\Assert;
+
+require __DIR__ . '/../bootstrap.php';
+
+/**
+ * @testCase
+ */
+final class ScaffoldCommandTest extends CommandTestCase
+{
+	public function testSuccess(): void
+	{
+		$input = new ArrayInput(['scaffold', 'definition' => __DIR__ . '/definitions/good.definition.php']);
+		$output = new BufferedOutput();
+
+		$exitCode = $this->runCommand($input, $output);
+
+		Assert::same(0, $exitCode);
+		Assert::matchFile(__DIR__ . '/ScaffoldCommandTest.expected.success.txt', $output->fetch());
+	}
+
+	public function testVerbose(): void
+	{
+		$input = new ArrayInput(['scaffold', 'definition' => __DIR__ . '/definitions/good.definition.php']);
+		$output = new BufferedOutput(BufferedOutput::VERBOSITY_VERBOSE);
+
+		$exitCode = $this->runCommand($input, $output);
+
+		Assert::same(0, $exitCode);
+		Assert::matchFile(__DIR__ . '/ScaffoldCommandTest.expected.verbose.txt', $output->fetch());
+	}
+
+	public function testVeryVerbose(): void
+	{
+		$input = new ArrayInput(['scaffold', 'definition' => __DIR__ . '/definitions/good.definition.php']);
+		$output = new BufferedOutput(BufferedOutput::VERBOSITY_VERY_VERBOSE);
+
+		$exitCode = $this->runCommand($input, $output);
+
+		Assert::same(0, $exitCode);
+		Assert::matchFile(__DIR__ . '/ScaffoldCommandTest.expected.veryVerbose.txt', $output->fetch());
+	}
+
+	public function testFailure(): void
+	{
+		$input = new ArrayInput(['scaffold', 'definition' => __DIR__ . '/definitions/failing.definition.php']);
+		$output = new BufferedOutput();
+
+		$exitCode = $this->runCommand($input, $output);
+
+		Assert::same(1, $exitCode);
+		Assert::matchFile(__DIR__ . '/ScaffoldCommandTest.expected.failing.txt', $output->fetch());
+	}
+
+	public function testInvalid(): void
+	{
+		$input = new ArrayInput(['scaffold', 'definition' => __DIR__ . '/definitions/invalid.definition.php']);
+		$output = new BufferedOutput();
+
+		$exitCode = $this->runCommand($input, $output);
+
+		Assert::same(1, $exitCode);
+		Assert::matchFile(__DIR__ . '/ScaffoldCommandTest.expected.invalid.txt', $output->fetch());
+	}
+
+	protected function createCommand(): Command
+	{
+		return new ScaffoldCommand();
+	}
+}
+
+(new ScaffoldCommandTest())->run();
diff --git a/tests/Console/check/.definition.php b/tests/Console/check/.definition.php
new file mode 100644
index 0000000000000000000000000000000000000000..e0ae4226abb7bcd385617c40bb219a613a331d47
--- /dev/null
+++ b/tests/Console/check/.definition.php
@@ -0,0 +1,18 @@
+<?php
+
+use function Grifart\ClassScaffolder\Capabilities\constructorWithPromotedProperties;
+use function Grifart\ClassScaffolder\Definition\definitionOf;
+
+return [
+	definitionOf(UnmodifiedClass::class)
+		->withField('field', 'string')
+		->with(constructorWithPromotedProperties()),
+
+	definitionOf(ModifiedClass::class)
+		->withField('field', 'string')
+		->with(constructorWithPromotedProperties()),
+
+	definitionOf(MissingClass::class)
+		->withField('field', 'string')
+		->with(constructorWithPromotedProperties()),
+];
diff --git a/tests/Console/check/ModifiedClass.php b/tests/Console/check/ModifiedClass.php
new file mode 100644
index 0000000000000000000000000000000000000000..6791c5cdd919a6717e6bd136b66feafe1058f417
--- /dev/null
+++ b/tests/Console/check/ModifiedClass.php
@@ -0,0 +1,14 @@
+<?php
+
+/**
+ * Do not edit. This is generated file. Modify definition file instead.
+ */
+
+declare(strict_types=1);
+
+final class ModifiedClass
+{
+	public function __construct(private int $field)
+	{
+	}
+}
diff --git a/tests/Console/check/UnmodifiedClass.php b/tests/Console/check/UnmodifiedClass.php
new file mode 100644
index 0000000000000000000000000000000000000000..8676cb14dda172bf99be0ba10c805bb8604914ec
--- /dev/null
+++ b/tests/Console/check/UnmodifiedClass.php
@@ -0,0 +1,14 @@
+<?php
+
+/**
+ * Do not edit. This is generated file. Modify definition file instead.
+ */
+
+declare(strict_types=1);
+
+final class UnmodifiedClass
+{
+	public function __construct(private string $field)
+	{
+	}
+}
diff --git a/tests/Console/definitions/.gitignore b/tests/Console/definitions/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..b6590d3daa765c7898ca0e26f32a8fa5a39c8582
--- /dev/null
+++ b/tests/Console/definitions/.gitignore
@@ -0,0 +1,2 @@
+*.php
+!*.definition.php
diff --git a/tests/Console/definitions/failing.definition.php b/tests/Console/definitions/failing.definition.php
new file mode 100644
index 0000000000000000000000000000000000000000..cef21d916be601d89dff1d01caded0d867c006b0
--- /dev/null
+++ b/tests/Console/definitions/failing.definition.php
@@ -0,0 +1,20 @@
+<?php
+
+use Grifart\ClassScaffolder\Capabilities\Capability;
+use Grifart\ClassScaffolder\ClassInNamespace;
+use Grifart\ClassScaffolder\Definition\ClassDefinition;
+use function Grifart\ClassScaffolder\Definition\definitionOf;
+
+return [
+	definitionOf(SuccessClass::class)
+		->withField('field', 'string'),
+
+	definitionOf(FailingClass::class)
+		->withField('field', 'string')
+		->with(new class implements Capability {
+			public function applyTo(ClassDefinition $definition, ClassInNamespace $draft, ?ClassInNamespace $current,): void
+			{
+				throw new RuntimeException('Oh no, I failed :(');
+			}
+		}),
+];
diff --git a/tests/Console/definitions/good.definition.php b/tests/Console/definitions/good.definition.php
new file mode 100644
index 0000000000000000000000000000000000000000..d97713ccf6c2468de43ae956681bcd6b911d87ca
--- /dev/null
+++ b/tests/Console/definitions/good.definition.php
@@ -0,0 +1,21 @@
+<?php
+
+use Grifart\ClassScaffolder\Capabilities;
+use Grifart\ClassScaffolder\Definition\ClassDefinition;
+use Grifart\ClassScaffolder\Definition\Types;
+use function Grifart\ClassScaffolder\Definition\definitionOf;
+
+function valueObject(string $className): ClassDefinition {
+	return definitionOf($className)->with(
+		Capabilities\constructorWithPromotedProperties(),
+		Capabilities\readonlyProperties(),
+	);
+}
+
+return [
+	$dataClass = definitionOf(valueObject(DataClass::class))
+		->withField('field', 'string'),
+
+	definitionOf(valueObject(AnotherClass::class))
+		->withField('data', Types\listOf($dataClass)),
+];
diff --git a/tests/Console/definitions/invalid.definition.php b/tests/Console/definitions/invalid.definition.php
new file mode 100644
index 0000000000000000000000000000000000000000..188daf9ed0e7d0bc40f2fd8b19ab52b2e3cd2510
--- /dev/null
+++ b/tests/Console/definitions/invalid.definition.php
@@ -0,0 +1,3 @@
+<?php
+
+return 42;