The MangoesToMetro ordering plugin accepts orders from the public internet, stores payment proof, moves orders through a lifecycle, and reports to a government partner on a schedule. This post covers how it is built: the state machine, the permission model, the upload handling, and the process that kept it honest.
The client-facing version of the same system is the ordering system case study.
The order state machine
An order has eight states. Rather than letting any code set any status, transitions are declared as a matrix and validated on every change:
'pending' => [ 'confirmed', 'delayed', 'cancelled' ],
'confirmed' => [ 'preparing', 'delayed', 'cancelled' ],
'preparing' => [ 'shipment', 'delayed', 'cancelled' ],
'shipment' => [ 'out_for_delivery', 'delayed', 'cancelled' ],
'out_for_delivery' => [ 'order_received', 'delayed', 'cancelled' ],
'delayed' => [ 'confirmed', 'cancelled' ],
'order_received' => [],
'cancelled' => [],
Three properties fall out of this that are worth naming.
Terminal states are terminal. order_received and cancelled map to empty arrays, so a completed order cannot be reopened and a cancelled one cannot be quietly resurrected. That is enforced by the data structure rather than by everyone remembering.
delayed is a holding state, not a stage. It is reachable from every active state and resumes to confirmed. This models what actually happens: a shipment stalls, someone marks it delayed, and when it resolves the order rejoins the pipeline. Modelling delay as a linear stage would have forced a fake ordering onto something that is not sequential.
Skipping is impossible. Nothing can jump from pending to out_for_delivery. A customer-visible status is therefore always one the operational sequence actually supports.
One transition carries a precondition rather than just a permission check: moving to shipment requires a PhilPost tracking number unless one is already recorded. The state machine is where a business rule like that belongs, because it applies no matter which screen triggered the change.

Transitions are appended to a status log table rather than only overwriting a column, so the history of an order is reconstructable after the fact.
Roles instead of handing out admin
The default answer to “this person needs to see orders” is a WordPress administrator account. That is how a store ends up with five administrators, any of whom can install a plugin or delete the site.
Three roles exist instead, each with the capabilities it actually needs:
add_role( 'bantalamarketing', 'Bantala Marketing', $caps );
add_role( 'dti_guimaras', 'DTI Guimaras', [
'read' => true,
'manage_orders' => true,
'view_orders' => true,
] );
add_role( 'admin_readonly', 'Admin (Read Only)', [
'read' => true,
'view_orders' => true,
] );
admin_readonly is the useful one. A partner organisation that needs to see order flow gets exactly that and nothing else. There is no version of “just look, do not touch” that a full administrator account can express.
Capabilities are checked per screen rather than per role, so the menu itself is conditional: view_orders gates the dashboard, manage_payment_qr gates the QR screen, manage_smtp gates settings, and export_orders gates the export. Checking the capability rather than the role name is what lets you add a fourth role later without hunting through conditionals.
Handling uploads from strangers
Accepting file uploads from unauthenticated visitors is the riskiest thing this plugin does. The dangerous case is a polyglot: a file that is a valid image by one measure and executable PHP by another.
The important decision is the direction of trust. The extension is derived from the sniffed MIME type, never taken from the uploaded filename:
$finfo = finfo_open( FILEINFO_MIME_TYPE );
$sniffed_mime = finfo_file( $finfo, $file['tmp_name'] );
finfo_close( $finfo );
$safe_ext = $mime_to_ext[ $sniffed_mime ] ?? '';
If the sniffed type is not in the allow list, the lookup yields nothing and the upload is rejected. A file called receipt.jpg that sniffs as application/x-php never gets a .jpg written to disk, because the name it arrived with was never consulted. That is then cross-checked against WordPress’s own wp_check_filetype_and_ext(), which compares the finfo result against the extension whitelist and returns empty on a mismatch.
Files are stored under a generated name rather than the submitted one, which removes path traversal and collision problems in one move. Belt and braces on top: the uploads directory refuses to execute PHP at the web server level, because an upload directory that can run code is a compromise waiting for a bypass.
Spam is rejected before any of this work happens. The submission is verified server-side with Cloudflare Turnstile before the plugin touches the file or the database, so an automated flood costs a token check rather than a file write.
CSV export and the injection nobody thinks about
The export button produces a spreadsheet of orders. Spreadsheet software treats a cell beginning with =, +, -, @, tab or carriage return as a formula. A buyer whose name is a formula becomes code that runs on the machine of whoever opens the export.
Every cell is escaped on the way out:
return array_map( [ __CLASS__, 'escape_csv_cell' ], $cells );
Prefixing a single quote forces the cell to be read as text. It is four lines of code guarding a hole that most order exports in the wild have wide open, precisely because the input is “just a name”.
Scheduled work
Four scheduled hooks carry the recurring work:
bsmf_daily_report
bsmf_send_review
bsmf_send_reorder_reminder
bsmf_send_transactional_email
The daily report goes to DTI at a configurable time, read from the mtm_daily_report_time option so changing it is a settings edit rather than a deployment.
The detail that bites people: hook callbacks must be registered on every request, not inside the activation routine. Registering a cron callback at activation produces a scheduled event with nothing listening to it, and the symptom is a job that silently never runs. Registration and scheduling are deliberately separate concerns here for exactly that reason.
Transactional emails for confirmed, shipment and out_for_delivery are dispatched asynchronously rather than during the status update request, so a slow mail server cannot make the dashboard feel broken.

The process that kept it honest
Two rules applied to every change.
The test came first and had to fail first. A test that has never failed proves nothing; it might be asserting on a mock of itself. Watching it fail for the expected reason is the only cheap evidence that it tests the thing you think it does.
Every change got two independent reviews before merge. Not one reviewer twice, two reviewers who had not seen each other’s findings. The disagreements were the valuable part. Where two reviewers with different priors both flagged the same line, it was almost always a real defect; where they disagreed, the argument surfaced an assumption nobody had written down.
For a system that handles other people’s money and addresses, that is a proportionate amount of ceremony. It is also what made a later maintainability pass safe to attempt at all.
What I would do differently
The plugin is still called bantala-smtp-form, a name from when it was a contact form. It now owns the order lifecycle, roles, uploads, scheduling and export. Rename things when their scope changes; the cost compounds quietly.
I would also lift the state machine out of the service class into its own unit sooner. It is the most valuable and most testable thing in the plugin, and it deserved to be isolated from the first day rather than the day I noticed.
Read next
The front end of the same order flow is in Building an accessible WordPress order form.
