What This Is
A tested workflow for taking a running PHP site or application, with its data, users and URLs, to the Internet Computer. The end state:
Browser ── HTTPS ──> HTTP gateway ──> frontend canister (@dfinity/static-site, certified-assets)
│ built HTML/CSS/JS, public media, _redirects, _headers
└── signed canister calls (Candid) ──> backend canister (Motoko)
data, business rules, import endpoints
Sign-in: Internet Identity Scheduled jobs: timers Outbound APIs: HTTPS outcalls
Domain: custom-domains No PHP runtime, web server, database server or OS left to patch
Why this shape: the static-site canister certifies every response ahead of time, so it serves a finite set of files. PHP renders per request; on the IC, pages are built ahead of time or rendered in the browser from backend data. Do not serve PHP templates from the Motoko canister's http_request as query responses: gateways verify those against certified data, and the certification library is Rust-only. Keep Motoko a typed API. For the few routes that must render per request, and for certification opt-outs, read references/delivery.md before deciding.
This skill orchestrates the migration. Load these skills for the details it delegates: writing-motoko (all Motoko code), migrating-motoko-actors (state changes after go-live), mops-cli, icp-cli, static-site, internet-identity, canister-security, https-outcalls, custom-domains, cycles-management; and when relevant multi-canister (data beyond one canister), vetkeys / encrypted-maps (data that must stay confidential), deploy-to-cloud-engine and cloud-engine-canisters (organization-owned capacity).
Prerequisites
- The PHP source (repository or a copy of the web root), a read-only database account or a recent export, and a copy of the upload directories.
icp-cli,ic-wasm,mops(seeicp-cliandmops-cli), Node.js >= 22, Python 3, and themysql/mariadborpsqlclient.- Production database write access is never needed. Never copy secret values (database passwords, SMTP or API keys) into the new code, the canister, batch files, reports or chat.
Ground Rules
- The old site keeps running, patched, until cutover. Work from copies. Nothing in the old system changes before the write freeze in Phase 6.
-
Track every feature in a feature map and update it as you go. One row per route, form, job, integration, role and table:
Feature Where in PHP New home Status Evidence News article page news.php,.htaccessrule 1static page per item + getNewstested /nieuws/12→ 200 locallyStatus moves
found → built → tested → data moved → verified live. A feature without evidence is not done; a feature you cannot rebuild is a listed gap, never silently dropped. -
Get the owner's sign-off on the plan (Phase 2), then work through reversible steps without asking again. Ask again before the write freeze, the DNS change and decommissioning; these are the irreversible ones.
- "It builds" is a checkpoint, not the finish. Done means the agreed features work at the live URL with migrated data, and the hand-over evidence below exists.
Phase 1: Inventory
Run the read-only inventory script from references/inventory.md at the PHP project root:
bash php-inventory.sh > inventory.md
It reports the platform (WordPress, Drupal, Joomla, Magento, Laravel, Symfony, CodeIgniter or plain PHP), entry points, .htaccess rewrite rules, which files use sessions, logins, forms, uploads, email, outbound HTTP, shell commands, image/PDF libraries and the database, plus SQL tables, scheduled jobs, upload directory sizes, and the names (never values) of configuration secrets.
Then collect what the script cannot see:
-
Row counts and sizes, for reconciliation and capacity planning (MySQL/MariaDB):
SELECT table_name, table_rows, ROUND((data_length + index_length) / 1048576, 1) AS mb FROM information_schema.tables WHERE table_schema = DATABASE() ORDER BY mb DESC;table_rowsis an estimate for InnoDB; reconcile againstSELECT COUNT(*). -
The URLs that matter:
sitemap.xml, the CMS's URL/alias table (seereferences/frameworks.md), and the most-requested paths from the access log:awk '{print $7}' access.log | sort | uniq -c | sort -rn | head -2000(common/combined log format). - Logged-in and admin journeys: a public URL never shows private logic, roles or data. Read the code for them.
For WordPress, Drupal, Joomla, Magento, Laravel and Symfony, read references/frameworks.md now: where content, users, URLs and schedules live, and the platform-specific traps (serialized PHP values, EAV tables, shortcodes).
Phase 2: Plan What Moves Where
| On the PHP site | On the Internet Computer | Watch out for |
|---|---|---|
| Server-rendered pages and templates | Pages built ahead of time into the static-site dir, or a browser-rendered app that calls the backend |
Content edited daily: render it in the browser from backend data, or rebuild and sync from CI with an authorized syncer principal (static-site) |
| Controllers, includes, plugins | public functions on a Motoko actor, query for reads |
SQL joins and transactions become explicit code; one update call is all-or-nothing unless it awaits |
| Tables | One Map per table keyed by the legacy primary key, extra Maps as indexes |
Heap is 4 GiB (wasm32) / 6 GiB (wasm64) per canister; beyond a few GiB of data, split canisters (multi-canister) |
| Public media (images, PDFs) | Files in the static-site dir, uploaded on deploy |
The static-site canister has no runtime upload API |
Runtime uploads ($_FILES) |
Chunks of at most 1 MB per call into the backend, with per-user quotas | 2 MB request/response limit; unbounded storage is a denial-of-service vector (canister-security) |
$_SESSION, login forms, password hashes |
Internet Identity and caller |
Phase 5; never import password hashes |
| CSRF tokens | Remove them for canister calls | Each call is signed by the session key the app holds; no ambient cookies are sent |
cron |
Motoko timers (mo:core/Timer) armed by a controller-only method |
Under the migration chain the actor body cannot have side effects; re-arm after each deploy |
mail(), SMTP, PHPMailer |
HTTPS outcall to a transactional email API, non-replicated or with an idempotency key | Replicated POSTs are sent once per replica (https-outcalls); where the API key lives is an owner decision (security acceptance) |
curl, Guzzle |
HTTPS outcalls | HTTPS and public endpoints only |
| Incoming webhooks, payments | Owner decision: certified custom http_request or an external receiver |
Flag as a decision; never stub it |
exec, GD/Imagick, PDF libraries |
Build time, the browser, or an external service | No processes or native extensions in a canister |
| Composer packages | Mops packages, or reimplement | Most have no Motoko equivalent; list each in the feature map |
| Web server, PHP-FPM, OS | Nothing |
Choose the target with the owner:
- A. All on ICP (default): frontend and backend canisters, the existing domain pointed at the frontend.
- B. Keep the existing frontend host: the rebuilt static frontend stays on the owner's host or CDN and calls the Motoko backend on ICP (tested cross-origin). Without the
ic_envcookie the canister id is page configuration; on mainnet pass no host or root key, locally take both fromicp network status --json. The external host can change the JavaScript that talks to the backend, so it stays an integrity dependency, and it is the Internet Identity origin (Phase 5).
Choose where it runs: icp deploy -e ic picks an application subnet. For data-residency rules, create the canisters with --subnet <subnet-id> on a subnet whose node locations fit (ICP dashboard); the flag only affects creation. For an organization's own cloud engine, --engine <engine-name> with deploy-to-cloud-engine and cloud-engine-canisters loaded (engines charge no cycles; never attach cycles there).
Present the feature map, the target and the gaps; get sign-off.
Phase 3: Build
Scaffold with icp-cli and mops-cli. Follow the file layout and rules of writing-motoko (types.mo, lib/, mixins/, main.mo, a migrations/ chain). A complete, deployed and tested backend for a CMS-style site is in references/backend-example.md. Its icp.yaml:
canisters:
- name: backend
recipe:
type: "@dfinity/motoko@v5.1.0"
- name: frontend
recipe:
type: "@dfinity/static-site@v0.4.0"
configuration:
dir: dist
Backend rules, each for a reason:
- Key every imported table by its legacy primary key (
Nat). Old URLs, foreign keys and safe re-imports all depend on it. - Import methods are controller-only (
caller.isController(): the identity that deploys), cap the batch size, and upsert, so a failed run can simply be repeated. - Read methods paginate. A response over 2 MB traps; never return a whole table.
- Every public update method checks its caller and rejects the anonymous principal where sign-in is required (
canister-security). - Map column types deliberately: money as
Natin minor units, neverFloat; time asIntnanoseconds since 1970 UTC (the unit ofTime.now()); nullable columns as?T;ENUMas a variant. - Stable fields have no initializers: the first migration file supplies them (
migrating-motoko-actors). After go-live, every state change goes through that skill; keep import row types separate from stored types, oricp deployrefuses the changed method signature (see the example). - Scheduled jobs: a controller-only
startJobs()cancels the previous timer, then armsTimer.recurringTimer<system>; call it after every deploy.
Frontend rules:
- Keep old URLs by building pages at their old paths. The canister serves
dist/nieuws/12.htmlat/nieuws/12anddist/afval.htmlat/afval(clean URLs), so old pretty links work with no redirect at all. A URL ending in a slash (WordPress) is a directory:dist/afval/index.htmlis served at/afval/, and/afvalredirects to it. - Renamed paths get exact
_redirectsrules generated from the export. There are no:splatcaptures. - Query-string URLs (
index.php?page=…,news.php?id=…) cannot be matched by_redirects, which ignores the query string. Rewrite the script path (/index.php /legacy.html 200) to a small page whose script maps the parameters to new paths. Generator and page:references/old-urls.md. - Add
_headers. The static-site canister sends no default security headers (static-site). - Call the backend through generated bindings (
@icp-sdk/bindgen, seeicp-cli): canister id and root key come from theic_envcookie, never a hard-coded host;opt Tarrives asT | null. Sign in with Internet Identity (internet-identity) where a method needs a caller.
Phase 4: Move the Data
Full commands and the converter are in references/data-import.md. The sequence:
- Back up the old database (
mysqldump --single-transaction); this copy is never modified. -
Export each table as JSON lines, one object per row, with the columns already renamed to the Motoko field names:
mysql --default-character-set=utf8mb4 --batch --raw --skip-column-names -e " SET time_zone = '+00:00'; SELECT JSON_OBJECT('legacyId', id, 'slug', slug, 'title', title, 'bodyHtml', body, 'published', published, 'updatedAt', UNIX_TIMESTAMP(updated_at)) FROM pages ORDER BY id;" > export/pages.jsonl -
Convert to Candid batch files with the stdlib-only
jsonl_to_candid.py(field typesnat,int,text,bool,time,variant,opt:<type>):python3 jsonl_to_candid.py export/pages.jsonl batches/pages \ legacyId:nat slug:text title:text bodyHtml:text published:bool updatedAt:time -
Import, stopping at the first failure (re-running is safe because imports upsert):
for f in batches/pages-*.did; do icp canister call backend importPages --args-file "$f" -e ic || { echo "failed at $f"; break; } done -
Reconcile: counts from
importCountsagainstSELECT COUNT(*)per table; spot-check records containing accents, emoji, quotes, tabs and backslashes; compare SHA-256 hashes of migrated files. Keep the results for the hand-over. - Snapshot the populated backend before anything risky:
icp canister stop backend -e ic && icp canister snapshot create backend -e ic && icp canister start backend -e ic.
Phase 5: Users and Sign-In
- Do not import password hashes. Internet Identity cannot use them, and canister memory on standard application subnets is readable by node operators. Import identity data only (legacy id, email, display name, role).
- Decide the final domain before real users sign in. Internet Identity derives a different principal per origin. It treats
ic0.app,icp0.ioandicp.netas one origin, but a custom domain is a different one, and so is the external host in target B. Moving origins later needsderivationOrigin(internet-identity, "Serving an app at more than one origin"). - Link legacy accounts only with verified proof, never an email the user merely typed: either the Internet Identity
verified_emailattribute matches the imported address (notemail, which II does not verify; SSO emails only for domains you list), or a one-time code shown on the old site while both run. Seeinternet-identityfor attribute verification in the backend. - Staff and editors: organization SSO through Internet Identity (
ssoDomain), or an explicit principal allowlist that only controllers can change.
Phase 6: Cut Over
- Deploy to the chosen network, subnet or engine; fund it (
cycles-management,buy-cycles). Commit.icp/data/mappings/ic.ids.json: it holds the canister IDs. - Run the new site side by side at
https://<frontend-id>.icp.net. To keep a preview private, use the static-site access protection. - With the owner's go-ahead, freeze writes on the old site (maintenance or read-only mode), re-run the export and import (a full re-run is safe; or only rows changed since the last export), and reconcile again.
- Snapshot the backend.
- Add
.well-known/ic-domainsto the frontenddir, register the domain (custom-domains), then, with the owner's go-ahead, switch DNS. - Verify live on the real domain: every journey in the feature map, sign-in, forms, uploads, old URLs (pretty paths, renamed paths, query-string URLs), the 404 page, and an upgrade of the backend with production data in place.
- Keep the old stack read-only and patched for the agreed rollback window, then decommission it and securely delete exports that contain personal data.
Rollback is two different things. Redeploying old code does not undo data changes. To return the backend to a known state, stop it and run icp canister snapshot restore backend <snapshot-id> -e ic, then start it.
Security Acceptance
- Link only verifying hostnames (
<id>.icp.netor the custom domain), never<id>.raw.icp.net, which skips certificate checks. - Query calls are answered by a single replica. For integrity-sensitive reads use update calls or certified data (
certified-variables). - Consensus protects integrity, not confidentiality. Keep secrets out of canister state; treat personal data as readable by node operators on standard subnets, or encrypt it client-side (
vetkeys,encrypted-maps). - Controllers can replace the code. Record who they are, add a backup controller, and protect those keys (
canister-security). - For the frontend, the static-site state hash lets anyone check the deployed files against a rebuild from source (
static-site). - In target B, write down that the external frontend host remains a trust dependency. Do not call that setup tamperproof end to end.
Hand-Over Evidence
Deliver: live URL(s); canister IDs, network and subnet or engine; source location and the exact build, deploy and import commands; the feature map with evidence per row; reconciliation results; test results, including the populated-state upgrade; controllers and backup controller; cycles balance and monitoring; snapshot IDs and the rollback procedure; and a list of every gap and intentional difference. If access, funding or an owner decision blocks deployment, finish all independent work and name the exact blocker; never report an undeployed build as done.
Pitfalls
- Exporting without
--raw. In batch mode themysql/mariadbclient escapes backslashes, tabs and newlines in values again, soJSON_OBJECToutput with\nor\"inside becomes unparseable or, worse, parses with corrupted text. Always pass--batch --raw --skip-column-names. opt 5 : natin Candid text. The annotation binds to the whole option and the call fails withtype mismatch: opt 5 cannot be of type nat. Writeopt (5 : nat); the converter does this.- Sending a whole table in one call. Requests and responses are limited to 2 MB. Batch imports and paginate reads.
- Non-idempotent imports. An import that appends instead of upserting creates duplicates when a failed run is repeated. Upsert by legacy id and keep secondary indexes in sync (remove the old index entry when a key such as a slug changes).
- Unguarded import methods. Every update method is callable by anyone on the internet. Gate imports on
caller.isController(). - Expecting
_redirectsto match query strings or capture segments. Neither is supported: certification needs every response enumerable ahead of time. Build pages at old paths, generate exact rules, and use the mapping page for query-string URLs. - Rendering pages from the Motoko canister's
http_request. Uncertified responses are rejected by verifying gateways, and Motoko has no HTTP-certification library. Serve pages from the static-site canister. - Importing secrets or password hashes into canister state. Node operators on standard application subnets can read canister memory.
- Changing the sign-in origin after launch. Users get new principals on a new origin and lose access to their data unless
derivationOriginis configured. - Wrong time zone on
DATETIME.UNIX_TIMESTAMP()reads aDATETIMEin the session time zone. SetSET time_zoneto the zone the PHP app wrote in (date.timezone,date_default_timezone_set);TIMESTAMPcolumns are stored in UTC and need no adjustment. - Double-encoded text. Older MySQL sites often hold UTF-8 bytes in
latin1columns; exports then showéforé. Detect it on spot-checks and fix it in the export query (seereferences/data-import.md), never in the canister afterwards. - Serialized PHP in columns (
a:2:{…}in WordPresswp_optionsandwp_postmeta, many plugin tables). Decode during export with the PHP CLI; do not import the raw strings. - Starting timers in the actor body. The migration chain disallows side effects there. Arm them from a controller-only method and call it after every deploy.
- Leaving
--default-persistent-actorsin[moc] argson moc 2.x. Actors are persistent by default from moc 2.0.0 and the flag was removed, so the build fails. Keep it only on moc 1.x, where theicp newtemplate sets it. - Replicated POST outcalls to email or payment APIs. Every replica sends the request. Use an idempotency key or a non-replicated outcall (
https-outcalls). - Assuming default security headers. The static-site canister adds none; declare them in
_headers. - Reporting success from exit codes. An importer that exits 0 has not proven the data is right. Reconcile counts, spot-check records and verify journeys on the live origin.
- Local security tests can lie. A fresh icp-cli deploys as
anonymous, then the only controller, so anonymous calls passcaller.isController()locally. Hand the canister to a named identity first (icp canister settings update backend --add-controller <dev> --remove-controller 2vxsx-fae -e local).