Road to Hell is Paved with Strings

In 7 Traits of Successful Upgrade Companies, we wrote about the behavior patterns of companies that make the impossible upgrade happen.

Today we look closely at an anti-pattern that repeats in every framework, in every custom solution, and even in testing tools. A lot of CLI tools that should help us write better code also suffer from it. Frameworks are trying to get rid of it, but most drag mountain piles of history BC promise and so never do.

In this post, we'll look at spotting this anti-pattern, think about better ways to achieve the same result, and share our strategy to get rid of it in our projects.

First, here are 4 code examples we keep seeing in legacy projects we're hired to upgrade. Maybe you'll know them:

return [
    'sync' => 'autoamted',
    'aws-region' => 'us-east-1',
    'aws-access-key' => 'ENV(AWS_ACCESS_KEY)',
];

or

parameters:
    paths:
        - src
        - tests

    ignoreError:
        - "#Missing type#"

or

services:
    -
        class: "App\\Controller\\HomepageController"
        autowiret: true

or

return [
    'connections' => [
        'sqlite' => [
            'host' => 'url:sqlite:///:memory:',
        ],
        'prefix' => '',
    ]
];

The first snippet is a custom code, but snippets 2-4 are coupled to a project you know. They're coupled to a documentation you've read, on Stackoverflow or GPT.

All the snippets include tyop that would break the functionality. Have you noticed them on the first scan?


"If we have to read documentation to understand the configuration,
it sounds more like hardware than software."

We have to know that:

  • "autowired" is a Symfony service configuration keyword
  • it can be located in the services section under the service definition
  • it's not autowire**t** but autowire**d**

Why does this Matter?

Imagine we have a flight company that handles flights between the US and the UK. We have quality control that checks all electronics on the plane work, that tires have well-defined pressure, that the fuel is enough, and so on. Everything is fully automated and we get any report on exceptional values across time. Sounds safe enough for you to fly? Also, think about the main business functions of airlines - to maximize profits, we have to cut costs to a minimum.

We have a fuel tank, sealed tight with dozens of screws. Should we check manually every screw?

What if they change the fuel for a more effective one, that accidentally speeds up rusting of marginal material that our screws are using?


What if someone forgets? We don't want to think about these problems. We already put our attention into the new software that we've deployed last week.


Protect your Deep Work

This is called cognitive load and it's the root of many fatal bugs in software. This is how it works:


There is a great book called Don't Make Me Think, that hits this problem in a very entertaining way.


"Too many Cooks spoil the Broth"

Some teams can upgrade their project from PHP 5.3 to 8.3 themselves. Other teams too, but it turns into a costly 3-year project with half-team full-time effort. Some other teams are unable to do it in a timely matter and are stuck with the "if it works, don't touch it approach" that causes ever-growing losses in the software business.

The latter teams have to deal with so many edge cases, and WTFs per day, that they're pushing their abilities to the limit just to keep projects running. There is a short road to burnout from there.

That's why it matters: the code must be readable and easy to understand, even to a fresh developer... or to a fresh GPT that reads the code for the first time too.

Enable the Power of Static Analysis

Another reason is the power of static analysis - in PHP we have PHPStan, static analysis for TWIG or for Blade, static analysis for Behat and more.

This static analysis would warn us about 3 typos in the examples above. We'd know what we should fix before merging the pull request.


But what about other file formats other than PHP? That's the weak link.

YAML, ini, XML, JSON, NEON or Gherkin bellow. These all are parseable formats, they're all parsed into strings.

Feature: Reading code that looks like a string,
    but actually calls PHP method

  Scenario: Avoid thinking about code
    Given am a lazy human
    Then I delegate everything to static analysis

We have to change those into PHP format first. Sometimes it's one of the formats the tool already handles.

Sometimes we have to come up with our own format like in case of Behat PHP Config - fortunately, it's pretty easy and straightforward with php-parser.




Now let's say we actually have all configs in PHP syntax. Is that good enough to avoid legacy forever?

return [
    'sync' => 'autoamted',
    'aws-region' => 'us-east-1',
    'aws-access-key' => 'ENV(AWS_ACCESS_KEY)',
];

No, it's not.

  • we still made a typo
  • we don't know what is configuration option and what is our written value
  • PHPStan is running, but silent

Configuration Objects to the Rescue!

I'll share a few examples, so you have a better idea about the shape we want to get in. First, let's convert our 1st example to a configuration object:

return AwsConfig::configure()
    ->syncAutomated()
    ->withAwsRegion(RegionList::US_EAST_1)
    ->withAwsAccessKey(EnumKey::AWS_ACCESS));

Now we have:

  • typo-proof IDE method autocomplete ✅
  • IDE autocomplete for enums ✅
  • PHPStan warning us about invalid types ✅
  • PHPStan reporting deprecated methods ✅

In 2024, the Laravel 11 introduced streamlined configs:

return Application::configure()
    ->withProviders()
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
    )
    ->withMiddleware(function (Middleware $middleware) {})
    ->withExceptions(function (Exceptions $exceptions) {})
    ->create();

ECS uses PHP config object since 2022:

// ecs.php
use PhpCsFixer\Fixer\ListNotation\ListSyntaxFixer;
use Symplify\EasyCodingStandard\Config\ECSConfig;

return ECSConfig::configure()
    ->withPaths([__DIR__ . '/src', __DIR__ . '/tests'])
    ->withRules([
        ListSyntaxFixer::class,
    ])
    ->withPreparedSets(psr12: true);

In 2021, Symfony 5.3 shows state of the art autogenarated-configs:

// config/packages/security.php
use Symfony\Config\SecurityConfig;

return static function (SecurityConfig $security): void {
    $security->firewall('main')
        ->pattern('^/*')
        ->lazy(true)
        ->anonymous();

    $security->accessControl(['path' => '^/admin', 'roles' => 'ROLE_ADMIN']);
};

Automate and Forget

Once we have PHP in place, we move to the configuration object and get PHPStan with deprecated rules on board, we can forget about the configuration syntax. We can focus on the business logic and let the configuration be handled by the tool.

  • Next time you use Symfony - modernize configs to best shape the framework provides.
  • Upgrade your Laravel project to 11 and use streamlines configs.
  • Create a PHP wrapper for the tool that uses that old format and is error-prone. Learn php-parser.

Happy coding!