Blog
Why a full rewrite often creates more risk than incremental refactoring
A full rewrite promises clean architecture, but it forces the team to rediscover years of business rules, integrations and production exceptions. Incremental refactoring reduces risk by replacing one controlled boundary at a time.
A rewrite starts with an incomplete specification
An existing PHP application often contains business knowledge that never reached the documentation: an old customer agreement, an import exception, a permission check added after an incident. Replacing the code means deciding what to do with that behaviour, not simply translating classes into a newer framework.
That does not make every historical rule worth keeping. Some are still required; others are workarounds or mistakes. The useful starting point is to separate behaviour the business needs from behaviour it wants to change.
What the rewrite estimate needs to include
An unsupported framework, tightly coupled modules or unreliable deployments are reasonable reasons to consider replacement. A clean implementation may remove constraints that are expensive to work around. But its estimate needs to include more than development: discovering requirements, reproducing integrations and permissions, migrating data, validating reports, training users and preparing recovery procedures.
Old and new implementations may coexist while this happens. That can mean fixes in two places, synchronisation, additional environments and more operational knowledge to maintain. The duration and cost depend on the scope and cutover plan; neither a permanent feature freeze nor a new system that always falls behind is inevitable. Incremental work also has a cost: temporary adapters and migration code need owners and a removal plan.
Start with the order calculation
Consider this hypothetical excerpt from a Symfony or Laravel application. The examples use project-owned types such as Order and Money; omitted methods and dependencies are not framework APIs. The later readonly classes require PHP 8.2 or newer.
final class LegacyOrderPriceCalculator
{
public function calculate(Order $order): Money
{
$total = $order->lineTotal();
if ($order->customer()->usesHistoricContract()) {
$total = $total->subtract(
$this->historicDiscount($order),
);
}
if (
$order->country() === 'DE'
&& $order->wasImportedBeforeTaxMigration()
) {
return $this->legacyGermanTaxCalculation(
order: $order,
amount: $total,
);
}
return $this->standardTaxCalculation(
order: $order,
amount: $total,
);
}
}The historic discount is applied before either tax branch. The German branch represents a fictional legacy exception, not a statement about German tax rules. Its calculation methods are deliberately omitted.
Before replacing this code, establish which orders take each path, how rounding works and whether old documents must remain reproducible. Business owners need to decide which rules remain required. A new implementation should not quietly turn an unclear rule into a different one.
Characterisation tests record behaviour, not correctness
A characterisation test captures an existing result so that a refactoring cannot change it unnoticed. The first example covers an imported order:
it('keeps the imported-order baseline', function (): void {
$order = OrderBuilder::new()
->forCountry('DE')
->importedBeforeTaxMigration()
->withNetAmount('199.95')
->build();
$result = $this->calculator()->calculate($order);
expect($result->currency())->toBe('EUR')
->and($result->amount())->toBe('237.94');
});The second covers a customer with a historical agreement:
it('keeps the historic-contract baseline', function (): void {
$order = OrderBuilder::new()
->forHistoricContractCustomer()
->withNetAmount('1000.00')
->build();
$result = $this->calculator()->calculate($order);
expect($result->amount())->toBe('950.00');
});The amounts 237.94 and 950.00 are hypothetical baseline values, not verified client accounting results. They cannot be derived from the excerpt: the calculation methods and fixture defaults are missing. In a real test, OrderBuilder and calculator() are project helpers, and the fixture must specify the relevant tax treatment, currency, discount and rounding rules.
Record observed behaviour, investigate surprising results with someone responsible for the process, and distinguish preservation tests from tests for an approved business change. Passing these two tests would not prove that every price is correct.
Migrate the data, including changes made during migration
Historical data can contain inconsistent null values, duplicates, missing references or fields whose meaning changed over time. Those are things to investigate, not defects to assume in every database. A manually corrected address may be more reliable than a fresh parser result; two similar customer records may belong to different people.
Migration therefore needs reconciliation as well as transformation. Check coverage, references and relevant totals, and put ambiguous records through an agreed exception process.
Expand-and-contract separates compatible additions from the later removal of obsolete structures. For an address migration, the stages might be:
Stage Condition for moving on
Add structures Old code still works
Backfill Old records converted in bounded batches
Reconcile Concurrent changes and exceptions resolved
Switch reads New data meets acceptance criteria
Retire old data No remaining readers or writers need it
During transition: define who owns each write.Adding nullable columns is only suitable where the model permits missing values; schema changes can also lock tables or put pressure on the database. Plan the actual database operation, not just the application deployment.
Dual-write is one option, not a requirement. A single writing path with a compatibility adapter, change capture or a planned write pause may fit better. If both representations are written, partial failures and concurrent updates need a consistency and reconciliation policy. A backfill that advances by ID will not automatically revisit an earlier record changed afterwards.
This service illustrates a bounded batch. Its constructor and the declarations of customers and legacyAddressParser are omitted:
final class CustomerAddressMigrationService
{
// Constructor and dependency fields omitted.
public function migrateBatch(
int $afterId,
int $limit,
): MigrationBatchResult {
if ($afterId < 0 || $limit < 1) {
throw new \InvalidArgumentException(
'Invalid batch cursor or size.',
);
}
$customers = $this->customers
->findLegacyAddressBatch(
afterId: $afterId,
limit: $limit,
);
foreach ($customers as $customer) {
if ($customer->hasStructuredAddress()) {
continue;
}
$address = $this->legacyAddressParser
->parse($customer->legacyAddress());
$customer->setStructuredAddress($address);
}
$this->customers->saveAll($customers);
return MigrationBatchResult::fromCustomers($customers);
}
}Here, findLegacyAddressBatch() must return a materialised batch ordered by a stable, unique integer ID, with id > afterId and a limit. MigrationBatchResult::fromCustomers() must report progress from the last scanned record, including records skipped by the loop, and distinguish an empty batch.
The skip condition avoids repeating one transformation; it does not prove that the stored address is current or correct. Nor does saveAll() imply a database commit. These are project methods whose contracts must define persistence, transaction ownership and error handling. Save a checkpoint only after the corresponding writes commit. Safe retries also require protection against concurrent overwrites and a way to recover or reconcile partial work. The excerpt alone is neither an idempotency guarantee nor a complete resumable migration.
Replace a capability behind a useful contract
For pricing, a small application-owned interface can give callers a stable dependency:
interface CustomerPricingInterface
{
public function calculate(
Customer $customer,
OrderDraft $order,
): PriceResult;
}The existing implementation becomes accessible through an adapter:
final class LegacyCustomerPricingAdapter
implements CustomerPricingInterface
{
public function __construct(
private readonly LegacyPricingManager $legacy,
) {
}
public function calculate(
Customer $customer,
OrderDraft $order,
): PriceResult {
$legacyResult = $this->legacy->calculate(
customerId: $customer->getId(),
lines: $order->lines(),
);
return PriceResult::fromLegacyResult($legacyResult);
}
}This is useful only when callers actually use the interface instead of bypassing it to reach the old manager. PriceResult::fromLegacyResult() must preserve the agreed currency, precision and meaning of the result; an interface alone cannot guarantee equivalent calculations.
The replacement implements the same contract. This skeleton fails explicitly because the new algorithm is outside the example:
final class ModernCustomerPricingService
implements CustomerPricingInterface
{
public function calculate(
Customer $customer,
OrderDraft $order,
): PriceResult {
throw new \LogicException(
'Candidate calculation is not implemented.',
);
}
}Do not select this placeholder in a deployed application. Implement and verify the calculation before enabling it.
Branch by Abstraction and Strangler Fig solve different routing problems
Branch by Abstraction lets implementations coexist behind a shared boundary while callers move to it and the replacement is introduced. The diagram shows dependencies and implementations, not the order in which methods execute:
Callers depend on: CustomerPricingInterface
Implementations of that contract:
LegacyCustomerPricingAdapter
ModernCustomerPricingService
Selection:
deployment configuration -> dependency injection
customer context -> CustomerPricingFactoryRuntime selection is useful when a migration policy genuinely varies by customer or request:
final class CustomerPricingFactory
{
public function __construct(
private readonly LegacyCustomerPricingAdapter $legacy,
private readonly ModernCustomerPricingService $modern,
private readonly PricingMigrationPolicy $policy,
) {
}
public function forCustomer(
Customer $customer,
): CustomerPricingInterface {
if ($this->policy->usesModernPricing($customer)) {
return $this->modern;
}
return $this->legacy;
}
}The factory chooses an implementation; the interface defines the calculation contract. If deployment configuration determines the choice for everyone, ordinary dependency injection is usually enough. A customer-level rollout also needs consistent routing and compatible data; switching a flag back is not sufficient if the old code cannot read newly written data.
Strangler Fig replaces parts of the application through a routing or facade boundary. For example, GET /api/orders/{id} can progressively move to a new read service while the public endpoint remains available. An internal query contract could be:
interface OrderDetailsQueryInterface
{
public function get(
int $orderId,
string $locale,
): ?OrderDetailsDto;
}Here null represents a missing order, not an authorisation decision. Both paths must preserve access checks and the public response contract. Branch by Abstraction concerns an internal dependency boundary; Strangler Fig concerns the gradual replacement of application capabilities. They can be combined, but are not interchangeable names. These are established techniques, not GiSoft inventions; references are listed below.
Compare calculations without duplicating business actions
For a side-effect-free calculation, running both implementations can reveal differences before switching the official result. They need equivalent input snapshots, reference data and rounding rules. Otherwise, a changed exchange rate or a mutated input can look like an implementation defect.
This synchronous diagnostic wrapper returns the official result if both calculations and reporting complete:
final class ComparingCustomerPricingService
implements CustomerPricingInterface
{
public function __construct(
private readonly CustomerPricingInterface $official,
private readonly CustomerPricingInterface $candidate,
private readonly PricingComparisonReporter $reporter,
) {
}
public function calculate(
Customer $customer,
OrderDraft $order,
): PriceResult {
$officialResult = $this->official->calculate(
customer: $customer,
order: $order,
);
$candidateResult = $this->candidate->calculate(
customer: $customer,
order: $order,
);
if (!$officialResult->equals($candidateResult)) {
$this->reporter->reportDifference(
customerId: $customer->getId(),
official: $officialResult,
candidate: $candidateResult,
);
}
return $officialResult;
}
}equals() needs a domain-defined comparison: currency, amounts, rounding and any meaningful breakdown, rather than object identity or arbitrary floating-point tolerances. The implementations must not mutate shared inputs or results.
This is not fault isolation. A candidate exception, a slow calculation or a reporter failure can still fail or delay the request. Production use needs an explicit policy for candidate failures, execution limits and reporting failures. Where isolation is required, a separate comparison task using a captured input snapshot may be preferable, with its own delivery and resource limits. Merely not returning the candidate result does not make it harmless.
The reporter receives potentially sensitive customer and pricing data. Restrict what it retains, who can access it and how long it is kept; full object dumps are unnecessary.
A payment decision illustrates the boundary between calculation and action:
final readonly class PaymentDecision
{
public function __construct(
public bool $allowed,
public string $reason,
public Money $amount,
) {
}
}Both implementations may calculate a decision. Only the authorised official path should perform the payment, after the existing permission and business checks. allowed is not itself authorisation. Repeated delivery or an ambiguous provider timeout still needs durable operation tracking and suitable idempotency handling; comparing two return values provides neither.
The same separation applies to emails, stock movements and refunds. Also, readonly does not make an embedded Money object immutable: that type must provide its own guarantees.
Keep persistence ownership explicit
A repository boundary can allow business services to work with a stable model while storage changes:
interface CustomerRepositoryInterface
{
public function get(int $id): Customer;
public function save(Customer $customer): void;
}An adapter can map between that model and the old persistence representation:
final class LegacyCustomerRepository
implements CustomerRepositoryInterface
{
public function __construct(
private readonly LegacyCustomerGateway $gateway,
private readonly LegacyCustomerMapper $mapper,
) {
}
public function get(int $id): Customer
{
$row = $this->gateway->findRequired($id);
return $this->mapper->toDomain($row);
}
public function save(Customer $customer): void
{
$this->gateway->save(
$this->mapper->toLegacyData($customer),
);
}
}In this illustrative contract, get() must have a defined not-found exception policy, while save() does not specify when a transaction commits. The gateway and mapper are project dependencies, not Doctrine APIs.
Mapping needs to preserve fields owned by other processes and protect against stale updates. Decide which system owns each field during coexistence. A separate domain model and repository interface are useful where they reduce a real migration constraint, not as mandatory layers for every entity.
Protect the public API separately from the implementation
An internal OrderDetailsDto can be mapped to a public response such as PublicOrderDto. Keeping this distinction explicit helps prevent storage changes from leaking into the API:
final readonly class PublicOrderDto
{
/**
* @param list<PublicOrderLineDto> $lines
*/
public function __construct(
public int $id,
public string $number,
public string $status,
public string $currency,
public array $lines,
public string $createdAt,
) {
}
}The string type of createdAt does not enforce a date format, and the PHPDoc describes the list elements for static analysis rather than validating incoming data.
A small response test is still useful:
it('returns the required public order fields', function (): void {
$client = static::createClient();
$client->request('GET', '/api/en/orders/1001');
self::assertResponseIsSuccessful();
$payload = json_decode(
(string) $client->getResponse()->getContent(),
true,
512,
JSON_THROW_ON_ERROR,
);
expect($payload)
->toBeArray()
->toHaveKeys([
'id',
'number',
'status',
'currency',
'lines',
'createdAt',
]);
});This Pest excerpt assumes a Symfony WebTestCase setup, a known order fixture and any required authentication. It checks a successful response, valid JSON and the presence of keys. It does not protect the entire contract.
Compatibility tests may also need exact status codes, value types, nullability, date formats, authorisation failures, error responses, ordering, pagination and translated fields. Preserve the existing contract unless a change has been explicitly agreed and coordinated with consumers.
Use types and architecture tests for the rules they can check
PHPStan can help expose mismatches at adapter boundaries. For example, these are interface-method excerpts, not standalone functions. The first describes a list of project-owned row objects:
/**
* @return list<LegacyCustomerRow>
*/
public function findLegacyCustomersForMigration(
int $afterId,
int $limit,
): array;A mapper contract can instead describe a specific array shape:
/**
* @return array{
* id: int,
* email: string,
* legacy_status: string|null,
* created_at: string
* }
*/
public function toLegacyData(Customer $customer): array;The mapper still needs to validate and interpret source data correctly. Accurate annotations help analysis; they do not prove that a historical status was translated into the right business meaning. PHPStan also needs appropriate configuration or custom rules to check project-specific architectural policies.
Pest architecture tests can express selected dependency restrictions:
arch('core excludes legacy infrastructure')
->expect('App\Core')
->not->toUse('App\Infrastructure\Legacy');
arch('API controllers exclude EntityManagerInterface')
->expect('App\Api\Controller')
->not->toUse('Doctrine\ORM\EntityManagerInterface');
arch('core excludes the provider SDK')
->expect('App\Core')
->not->toUse('Vendor\ExternalSdk');The namespaces are illustrative; Vendor\ExternalSdk is a placeholder. Adapt them to the real codebase and its installed Pest architecture-testing version. The controller rule forbids a direct dependency on EntityManagerInterface, not every possible database access. The SDK rule only excludes the SDK from App\Core; it does not establish that every SDK use is inside infrastructure. Dynamic lookups and indirect effects still need review.
Compare the whole read path, not just SQL counts
The following figures for GET /api/en/products are entirely hypothetical. They illustrate a trade-off, not a GiSoft benchmark:
Metric Existing Candidate
SQL queries 8 3
Database time 75 ms 110 ms
Serialisation 60 ms 190 ms
P95 response time 280 ms 430 ms
Memory 42 MB 118 MBLarger joins, extra hydration or more expensive mapping can outweigh a lower query count. The component timings shown here should not be added to derive P95: that percentile describes a distribution of complete requests.
A useful comparison controls the dataset, payload, traffic mix, concurrency, cache state and environment. Measure representative historical cases as well as the common path, and agree acceptable error rates and latency before rollout.
Give AI-assisted changes a bounded scope
A coding assistant can help extract an adapter or update callers, but plausible code can still change a null convention, discount branch or API field. The review should follow the actual behaviour, not the apparent neatness of the diff.
A useful task identifies the public and business contracts, points to characterisation tests and limits the change to one boundary. Missing tests should be added before changing the behaviour they are meant to protect. Review query changes with measurements, and run focused tests plus static analysis over the relevant dependencies and callers, not automatically just the edited files.
Changes to financial behaviour, permissions or schema need accountable human approval. Record what was checked and what remains uncertain. AI assistance does not remove the need for that decision.
When a full rewrite deserves serious consideration
A rewrite can be the better option when the product scope has changed substantially, a small system is well understood, or the current platform cannot meet important operational requirements at an acceptable cost. Conversely, unknown rules and difficult data migration can make an apparently clean start expensive. Neither observation decides the case on its own.
Use questions rather than a yes/no score:
- What must remain compatible? A deliberately smaller replacement differs from a promise to reproduce everything.
- Where can the change be separated? Useful boundaries favour staged replacement; creating them also has a cost.
- How will data and writes move? Difficult coexistence can complicate either approach, while a single cutover concentrates risk.
- What can the organisation operate and verify? Consider staffing, regulatory constraints, user acceptance, ongoing feature delivery and recovery options.
A database backup is not a complete rollback plan: restoring it may discard later writes or conflict with actions already performed externally. Rehearse the appropriate recovery or forward-repair procedure and define acceptance criteria before cutover.
A practical first step
Choose one important, bounded capability and document its current behaviour with developers and process owners. Protect the necessary cases, identify data ownership, then introduce only the boundary needed for replacement. Trial the candidate on a limited scope with explicit acceptance and recovery conditions.
Use what that trial reveals to revise the wider plan. Keep delivery work visible alongside migration work, and remove transitional code once its users and data dependencies are gone. A smaller step is valuable when it answers a real question; it is not an end in itself.
The aim is to preserve required business behaviour and data integrity while making future change safer. Sometimes that leads to gradual replacement, sometimes to a new application. The decision should rest on evidence about this system, not a preference for old or new code.
References
The named migration techniques and tool syntax are described in these sources. The order and address examples above are illustrative and do not describe a verified client implementation.
- Martin Fowler, Branch by Abstraction: https://martinfowler.com/bliki/BranchByAbstraction.html
- Martin Fowler, Strangler Fig: https://martinfowler.com/bliki/StranglerFigApplication.html
- Danilo Sato, Parallel Change (expand-and-contract): https://martinfowler.com/bliki/ParallelChange.html
- PHPStan, PHPDoc types: https://phpstan.org/writing-php-code/phpdoc-types
- Pest, architecture testing: https://pestphp.com/docs/arch-testing
