Managing APIs is hard. An application usually supports a single version of itself. It can be refactored, restructured, and redesigned with relative freedom. On the other hand, an API has to maintain stability for all its versions that are being consumed.

As requirements change, more and more time is spent on how to avoid breaking changes instead of actually delivering value.

Frozen in Time

An API is a contract between the provider and the consumer. This means that once a version is released, it should remain frozen in time. This is not only true for the contract of the API but also for its implementation. However, this is not a “should” but what happens in practice.

When an API is released, we don’t care about its internal implementation anymore. Yes, there will be bugs and yes, there are multiple shared components. But other than these, we do not make changes. If it works, don’t change it. Right?

Theoretically, we could develop a completely new application for each new version. This would allow us to build something and just make sure we keep it alive. Practically, the cost of maintaining such an approach is prohibitive. Furthermore, this is not how software is built in practice.

Software is built in small incremental batches. So why don’t we optimize our APIs for small incremental changes?

Problem

Code frozen in time sounds good, but what about code that reflects data? Data is also evolving with each new requirement. Take this for example:

 if ($course->active) {
    // do something
 } else {
    // do something else
 }

If we delete the active field in favor of a new status field, then we will have code that depends on a field that does not exist anymore. Even a new application wouldn’t help us here as there is still the dependency on the data.

There are ways to mitigate this problem. The easiest one is to check the version of the API and adapt the code accordingly.

 if ($apiVersion >= 2) {
     if ($course->status === 'active') {
         // do something
     } else {
         // do something else
     }
 } else {
     if ($course->active) {
         // do something
     } else {
         // do something else
     }
 }

As you can see, this approach quickly becomes unmanageable. Each new version adds more complexity to the code.

A more common approach is to use feature flags or to split each version in a different folder / class:

  - api
      - v1
          - CourseController.php
      - v2
          - CourseController.php

While this can work, it does not scale well with small incremental changes. Furthermore, you tend to lose what is the latest state of the application.

APIs as Infrastructure

To solve these problems, we decided to treat APIs as infrastructure. An approach first introduced by Stripe back in 2017.

The idea is simple. Your code always reflects the latest version of your API. Each time you need to introduce a change, you update your code to reflect the new requirements. Then, you add a VersionChange that lets you go back in time.

Instead of branching the system into multiple versions, we move the system forward and let transformations pull older versions backward. This keeps change concentrated in one place instead of fragmented across versions.

Let us build on our previous example. We have a CourseEntity that had an active field in V1 and in V2 introduced a status field. We would update our code to reflect the latest version, which means that our code would use only the status field.

To make sure we won’t break the previous versions we add a VersionChange that restores the active field from the status field.

 class ConvertCourseStatusToActiveChange {
    private string $description = 'The active field was replaced by the status field for ...';
 
    public function apply(CourseEntity $course): CourseEntity {
        $course->active = $course->status === 'active';
        return $course;
    }
 }

When a request comes in, we check the requested version and apply all the necessary changes to bring the data to the requested version.

sequenceDiagram participant Request participant API participant VersionChanges participant Response Request->>API: Version: 2025-11-14 API->>API: Latest Version: 2025-11-17 loop Apply changes until we reach requested version API->>VersionChanges: Apply 2025-11-16 changes API->>VersionChanges: Apply 2025-11-15 changes API->>VersionChanges: Apply 2025-11-14 changes end VersionChanges->>Response: Response from version 2025-11-14

Here’s why we like this approach:

  • The code always reflects the latest version of the API.
  • Small incremental changes are easy to implement.
  • Each version change has a mandatory description that explains why the change was necessary.
  • We can freeze old versions without duplicating code.

Treating APIs as infrastructure lets us evolve safely, incrementally, and without fear of breaking the past.

Keeping Versions Aligned With Reality

Most API versioning schemes assume that products evolve through major releases. Versions like example.com/api/v1/courses and example.com/api/v2/courses work well when changes arrive in large batches.

The problem is that major releases require coordination across departments and strict lifecycle planning. More importantly, they contradict everything we have said so far: $small_incremental_changes !== $major_release.

Small, steady changes are easier for consumers to adopt. Ideally, the versioning scheme should reflect that and communicate something meaningful to them.

Date based versioning ( YYYY-MM-DD ) does exactly that. Each version corresponds to a real point in time, making the incremental nature of our changes visible and predictable. It aligns the version history with how the API actually evolves, instead of forcing artificial release boundaries.

Design First, Change Maybe

Code will always remain a technical debt. Having a framework that supports change does not mean that we can avoid thinking about design. Some changes will always ripple through the system. We try to balance between over-engineering and pragmatism.

What matters is creating an environment where change is expected, guided, and safe. A structure that lets us introduce new behavior incrementally, without rewriting the past. An approach where old versions can be frozen with confidence, and new versions can evolve without fear.

By treating APIs as long-lived infrastructure rather than short-lived features, we make this balance possible. We keep the codebase aligned with the current truth of the system, we document why each version exists, and we ensure that past behavior stays accessible without forcing duplication or hacks.

Bonus

While we were experimenting with this approach, we found an open-source project that implements in FastAPI what Stripe describes in their blog post. Having a concrete implementation really helped us to implement this approach in PHP.

You can check it out here: cadwyn .