Build, review and ship WordPress, in one loop.
Model your content types, write hooks and APIs in modern PHP, review every change in a PR and ship it to every environment. When the live site changes, pull it back and go around again. Your content never moves.
$ npm i -g @loopress/cliGet startedthen lps project config connects your site and installs the plugin for you.
Everything but the content, in Git.
Your repo holds how the site is built and behaves. What your client writes stays in WordPress, where it belongs.
$ tree client-site client-site/ ├── acf/ │ └── group_product_delivery.json # field groups ├── menus/ options/ theme/ # configuration ├── cpt/ taxonomies/ meta/ roles/ # post types, taxonomies, meta keys, roles ├── hooks/ │ └── vat-notice.php # one class per file ├── api/ apps/ pages/ patterns/ # code ├── composer.json # plugins and themes, locked └── loopress.json # environments
| ACF field groups | Light |
| Forms, menus, options | Light |
| Custom post types, taxonomies, meta keys, roles | Light |
| Block theme styles | Light |
| Hooks, API routes | Full |
| Apps, pages, block templates and patterns | Full |
| Plugin lockfile | Full |
| Composer | Full |
| Posts, pages content, orders, users, media | never |
lps diff exits 0 in sync, 1 on drift. that's your CI gate.
One week, five client sites, the same loop every time.
Each site used to have its own ritual: FTP here, a plugin upload there, a note in a doc somewhere. Now they all go around the same loop.
“Can we show a delivery date, and say prices include VAT?”
One request, two kinds of change. The delivery date is an ACF field on the product, the notice is a WooCommerce hook. Both land as files in the same repo, next to each other.
// hooks/vat-notice.php use Loopress\Hooks\Attribute\Filter; class VatNotice { #[Filter('woocommerce_get_price_html')] public function notice(string $html): string { return $html . ' <small>incl. VAT</small>'; } }no add_filter, no functions.php. one class per file.
Open the PR, let CI look first.
The diff is only configuration and code. Products, orders and customers are never in it, so it stays short enough to actually read.
$ lps diff --env staging
exit 1 on drift, so CI says no before you do.
The client approved staging.
Push what you reviewed, then promote staging to production. Every push takes a snapshot first.
$ lps push --env staging $ lps promote staging production
same two commands on every site.
The shop manager renamed the delivery date field in wp-admin.
Fair enough, it's their shop. The diff shows the field group moved on the live site, one pull brings the new label into the repo before anyone builds on the old one.
$ lps acf diff --env production $ lps acf pull --env production --type field-groups $ git commit -am "chore: sync delivery date label"
the repo follows the site, not the other way round.
“The price looks weird on the cart page.”
Roll the hook back to the snapshot taken before Tuesday's push. Loopress first checks nobody changed it on the site since, so the undo can't clobber someone else's edit.
$ lps hook rollback --env production
undo now, the proper fix waits for Monday.
“We need an intake form, and practice area pages with the same fields.”
Build the WPForms form and the ACF field group on staging, where you can click through them. Then pull both down: they are JSON files in the repo from now on.
$ lps form pull --env staging $ lps acf pull --env staging --type field-groups $ git status --short ?? acf/field-groups/group_practice_area.json ?? forms/14-client-intake.json
built in the admin, kept in Git.
“Can the partners check the form before it goes live?”
They read the PR, not a screenshot. The diff against production shows the new form and the new field group, nothing else.
$ lps diff --env production --only form --only acf
one question in the PR instead of three calls.
Approved, the intake goes to the new partner.
A form push leaves notification settings alone by default, so a stray push can't redirect where submissions go. Here the new recipient is the point, so you say so.
$ lps acf push --env production $ lps form push --env production --allow-notifications
recipients are validated, the sender stays on the site's own domain.
The client added a menu item in wp-admin, on their own.
That is WordPress, the live site moves between your releases. The diff shows the drift, one pull brings it back into the repo, and the next Build starts from the real site.
$ lps diff --env production $ lps pull --env production $ git commit -am "chore: sync client menu edit"
this is why it's a loop and not a pipeline.
“WooCommerce wants to update itself again.”
Plugins are a lockfile in loopress.json. Bump the pin in the repo instead of clicking update on the site, and every environment installs the exact same version.
$ lps plugin add woocommerce --version 9.5.0 $ git diff loopress.json - "woocommerce": "9.4.2", + "woocommerce": "9.5.0",
the update is a one-line diff.
CI checks the new set before anyone merges.
The audit checks every pinned version against a vulnerability database and WordPress.org's health signals. Status shows how far staging is from the lockfile.
$ lps plugin audit $ lps plugin status --env staging
the audit exits non-zero on a known vulnerability.
Staging ran the new version for a day, all good.
Install the lockfile on staging, then promote it: production gets the versions staging actually ran, installed by Composer on the site, no SSH.
$ lps plugin push --env staging $ lps promote staging production
same plugins, same versions, everywhere.
Someone installed a plugin by hand on production.
Status spots it. Keep it, and a pull pins it into the lockfile at the version running on the site. Or don't, and the next push with --prune deactivates it.
$ lps plugin status --env production Active but untracked: hello-dolly $ lps plugin pull --env production
keep it or prune it, never leave it untracked.
“Checkout feels slower since Wednesday.”
Pin the previous version back. Downgrades need --force, because the old files come back but the database migrations the new version ran don't.
$ lps plugin add woocommerce --version 9.4.2 $ lps plugin push --env production --force
read the changelog before you downgrade.
“Parents want their kid's timetable, on the site and on their phone.”
A custom REST route, written as a plain class. The file path is the route, the bracketed segment is a parameter, and the attribute opens it to visitors.
// api/timetable/[class_id].php use Loopress\Api\Attribute\Permission; class Timetable { #[Permission(public: true)] public function get(WP_REST_Request $request): array { $class = $request->get_param('class_id'); return get_option("timetable_{$class}", []); } }answers at /wp-json/loopress-api/v1/timetable/6b.
The timetable page is a small Vue app, reviewed like the rest.
The route file and the app's source sit in the same PR. CI builds the app and diffs the route against staging, so the reviewer sees both halves of the feature.
$ npm --prefix apps/timetable run build $ lps diff --env staging --only api
front and back in one review.
Ready for the parents' evening.
Push the route, then the app. The app mounts in any page with a shortcode, the office can place it themselves.
$ lps api push --env production $ lps app push timetable --env production [loopress_app name="timetable"]
apps have their own build, so they ship with their own command.
A contractor hot-fixed the route on the server, over SFTP.
The fix works, but only on production. The diff shows it, one pull puts it in the repo, so the next push doesn't quietly undo it.
$ lps api diff --env production $ lps api pull --env production $ git commit -am "fix: keep the timetable hotfix"
the hotfix lives in Git now, not just on the server.
“New brand: a new accent color, and no sidebar on blog posts.”
Global Styles are one JSON file, the Site Editor's Styles screen in text. Templates are block markup files, the same HTML the Site Editor saves.
// theme/twentytwentyfive-global-styles.json { "settings": { "color": { "palette": { "custom": [ {"slug": "brand", "name": "Brand", "color": "#ff5a36"} ] } } } }plus templates/single.html, without the sidebar.
The client's designer reviews the PR.
The diff covers the styles file and the template, and it includes edits someone made in the Site Editor on staging, so nothing gets overwritten by surprise.
$ lps diff --env staging
a color change reads as a color change.
Signed off.
Theme push sends templates and parts as a child theme of the active one, then the Global Styles. The parent theme stays untouched and keeps its updates.
$ lps theme push --env production
the child theme is activated only when you say so, never on its own.
The designer nudged the heading size in the Site Editor.
Their tweak is good, keep it. The diff shows it, one pull writes it into the styles file, and the next push doesn't undo it.
$ lps diff --env production $ lps theme pull --env production
templates have no pull: Site Editor edits show in the diff, you copy them over.
Config first.
Code later.
Both freeBoth open source, GPL
Light puts your configuration in Git. It is on WordPress.org.
Full is the same plugin with the code half switched on, with conventions instead of functions.php. lps project config installs it for you.
WordPress.org doesn't host plugins that deploy code, that's the whole reason there are two. The full story.
Loopress Light · WordPress.orgLoopress Full · loopress.dev
configuration
- ACF
lps acf - Forms
lps form - Menus
lps menu - Post types
lps cpt - Taxonomies
lps taxonomy - Post meta
lps meta - Roles
lps role - Options
lps option - Global Styles
lps theme style
your code · switched off
- Hooks
lps hook - API routes
lps api - Apps
lps app - Pages
lps page - Templates
lps theme template - Patterns
lps pattern - Plugins
lps plugin - Composer
lps composer