Skip to content
DedicatedPHP Contact

How to Design a Change Approval Workflow in a PHP SaaS

A guide to deciding which changes require review, modeling their states and permissions, and applying them with auditing and concurrency control in a PHP SaaS.

Diagram of a data change approval workflow in a SaaS application

When a configuration or business data change can affect other users, a software application may need one person to propose it and another to review it before it takes effect. Designing this process as a change approval workflow in a PHP SaaS involves more than adding an “approve” button: you need to separate the proposal from the current data, protect every transition, and define what happens if the context changes while the request is waiting.

The goal is for the system to be able to answer, in a verifiable way, what was requested, who reviewed it, with what permissions, and whether it was applied. The solution should be proportional to the risk: an unnecessary workflow adds friction, while an insufficient one may allow incorrect changes or make them difficult to trace.

Decide which changes require approval

Decide which changes require approval — DedicatedPHP visual guide

Start by classifying changes by impact, scope, and how easily they can be corrected. Not every editable data item needs review. A user who changes a low-impact personal preference can save the change directly. By contrast, it may be reasonable to require approval for changing a billing rule, a setting shared by an organization, or master data that affects several processes.

Define explicit product criteria. For example, ask whether the change affects more users than the person proposing it, whether it can change amounts or permissions, whether internal policy requires a second person, and how much it would cost to correct. Also document exceptions and who can change them. Avoid turning approval into a general mechanism for operations unrelated to this case: the scope here is changes to configuration or business data.

The decision must be enforced on the server, not just in the interface. Hiding a button does not prevent someone from calling an endpoint directly. The API must determine whether the change type requires approval and enforce the rule for individual operations, bulk actions, and integrations that can change the same data.

Separate the request from the current data

Do not overwrite the current data when creating a proposal. Store a separate request containing, at a minimum, the organization, the affected resource, the proposed values, the status, who created it, and when. Depending on the product, it may also be necessary to store a reason, the request's origin, a deadline, or a version of the original data.

To represent the values, you can store typed fields or a serialized structure validated by the server. In either case, avoid accepting an arbitrary payload as if it were a direct update. Validate the schema, permitted fields, formats, and business rules when receiving the proposal. Revalidate whatever is relevant when applying it, because conditions may have changed while it was waiting.

This design makes it possible to compare the current value with the proposed one without changing the experience for other users. It also allows the review screen to show an understandable diff. Do not present a value that is only pending as current; make it clear which data has been applied and which is still a proposal.

Model explicit states and transitions

Define a small set of states with precise meanings. A common sequence may include proposed (proposed), in_review (under review), approved (approved), rejected (rejected), expired (expired), and applied (applied). Not every product needs a review state: if an immediate decision is sufficient, it can be omitted. What matters is that each state describes an operational situation rather than serving as a decorative label.

Specify the valid transitions. For example, a proposal can move to review or be rejected; a request under review can be approved or rejected; an approval can be applied if it is still valid. An expired request should not be approved through the same path as an active one. Disallowed actions must fail with a consistent response, even if the interface does not offer that option.

“Approved” and “applied” are not equivalent. Approval records a decision; applying the change modifies the current data. Keeping both stages makes it possible to detect failures when applying the change and show approved requests that still need attention. If the process can be completed within a single short transaction, the decision and the write can be coordinated, but the model should still distinguish what happened.

Verify permissions at every transition

Authorize every action on the server based on the organization, role, resource, and current state. Do not assume that being able to create a request grants permission to approve or apply it. Do not trust the organization ID sent by the client either: resolve the scope from a validated session or credential and check that the request and resource belong to that same scope.

If self-approval is prohibited, check that the approver's identity differs from the proposer's. This rule must be enforced during the transition, not just in the view. Also define what happens with requests from administrators, service users, and automated processes; exceptions require a deliberate policy, not an accidental omission.

An approval should be tied to a specific proposal. If its content changes, the previous approval is no longer valid: create a new version of the request or return it to a state that requires review. Silently changing values after approval breaks the link between what was reviewed and what was applied.

Handle concurrency, expiration, and applying changes

While a request is waiting, another actor might modify the original data. To detect this, record the resource version or relevant values when the proposal is created. When applying it, compare the expected version with the current one. If they do not match, do not overwrite the new change without a defined policy: block the write, mark a conflict, or request a new review, according to the product rules.

In a relational database, a transaction can protect the request read, the status check, and the resource update. The specific pattern depends on the database engine and concurrency level; pessimistic locking or a conditional update based on the version may be appropriate. In any case, the transition must be atomic: two simultaneous requests should not apply the same change twice.

Make applying the change idempotent or safely reject retries. If data is updated in the database and a notification is also sent or an event published, consider recording the pending event in the same transaction and processing it afterward. This reduces the risk of showing a success notification for a write that was not committed.

Also define an expiration policy: how long a proposal can remain active, what causes it to expire, and whether it can be renewed. Expiration must be checked on the server when approving and applying; changing the status through a scheduled task is not enough. If a delay means the values or conditions are no longer reliable, require a new proposal.

Audit and operate the workflow

Keep a record of who proposed, approved, rejected, and applied each change, including dates, organization and resource identifiers, the outcome, and the context needed for an investigation. Store a reference to the proposed version and the decision. The audit log should not be editable through normal request-editing functions. Limit recorded data to what is necessary and apply the product's access and retention policies.

The operational interface should help users find requests that are pending, expired, blocked by conflicts, or approved but not applied. Explain why an action is blocked and provide a safe way to resolve it; do not turn a failure while applying a change into automatic approval. Notifications can alert users to new tasks and decisions, but they are only an aid: the persisted, queryable state is the source of truth.

Test invariants, permissions, and failures

Test invariants, permissions, and failures — DedicatedPHP visual guide

Tests should verify rules that must never be broken. A request that has not been approved does not change the current data; someone without access to the organization cannot read or transition it; self-approval fails if prohibited; and an approved request cannot be applied with content different from what was reviewed. Add tests for terminal states, expired requests, and invalid transitions.

Also test race conditions between two approvals or between an approval and an edit to the original data. Simulate failures while applying the change and verify that the request is not marked as applied if the write was not committed. If there is a task that expires requests, check that running it repeatedly is safe. These tests turn business rules into verifiable guarantees and help maintain the workflow as permissions or the data model change.

As a design principle, each stage should have an owner, a verifiable condition, and an observable outcome. If the team cannot explain who is permitted to make each transition, what happens when a concurrent change occurs, or how to reconstruct a decision, the workflow still needs to be defined before it reaches production.

Want to apply these ideas to your project?Let’s discuss your PHP platform.
View related service