Install
Upload it under Plugins → Add New Plugin → Upload Plugin, then activate.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/etherlabz/simple-post-types/releases/download/v0.1.3/simple-post-types-0.1.3.zipEtherlabz Simple Post Types
A focused admin UI for registering custom post types, custom fields, and per-method API auth in WordPress.
No code, no menu-icon guessing, no register_post_type boilerplate. New types appear at /wp-json/wp/v2/{slug} the moment you save — ready for your headless front-end to consume, with optional per-method bearer-token auth so you can publish public submission forms while keeping the underlying data private.
Why this plugin
Every WordPress build needs at least one custom post type. The standard answer is register_post_type() in a theme functions.php — fast for a developer, opaque for everyone else. Plugins like CPT UI work but pile features on the screen and don't help with the modern needs that headless and submission-driven sites have:
- Custom fields that show up in the editor and on the REST API in one go
- Per-method API auth so you can have a public POST endpoint for contact-form submissions while keeping read auth-gated
- Method-disable for routes you simply don't want exposed (returns 405)
- Activity log so you can see who deleted what (and whether they purged data)
- Delete-with-data confirmation that's clear about what will and won't be erased
Simple Post Types is the focused 90% answer to all of that. One screen, one tab for activity history, zero of the legacy options that no one fills in correctly. It pairs with the sibling Etherlabz Headless Bridge plugin so any CPT you create here is automatically decorated with featured_image_url and seo fields on the REST API.
No Composer dependencies. No npm. No build step. Drop it in wp-content/plugins/ and activate.
Features at a glance
| Capability | Out of the box |
|---|---|
| One-screen UI for creating, editing, and deleting custom post types | ✅ |
| Slug, singular/plural labels, menu icon (curated dashicon picker + custom upload), menu position | ✅ |
| Editor "supports" pickers (title, editor, thumbnail, excerpt, author, comments, revisions, custom-fields, page-attributes) | ✅ |
All 12 register_post_type flags exposed (Visibility / Behavior groups) |
✅ |
| Taxonomy attachment (existing taxonomy slugs) | ✅ |
Auto-exposed at /wp-json/wp/v2/{rest_base} with full REST schema |
✅ |
| Custom fields per CPT — text, textarea, number, email, url, date, select, checkbox, image, color | ✅ |
Custom fields auto-render in the editor metabox + auto-expose on /wp/v2/{cpt} meta |
✅ |
| Per-method API auth — public / auth / disabled per HTTP method (GET, POST, PUT, PATCH, DELETE) | ✅ |
| Quick-preset chips: All public / Read public, writes auth / Submission only / Read-only / Write public, reads auth / All auth | ✅ |
Encrypted API key storage (AES-256-CBC keyed off AUTH_KEY/AUTH_SALT) |
✅ |
| Custom delete-confirm modal with explicit "Keep posts in DB" vs "Permanently delete data" choice | ✅ |
| Activity log — recent deletions with post count + data-purge flag + user, capped at 100 entries | ✅ |
| Tabs (Post types / Activity log) with sessionStorage + URL-hash persistence | ✅ |
Reserved core slugs (post, page, wp_block, …) blocked at validation |
✅ |
Strict slug regex (^[a-z][a-z0-9_-]{0,19}$) enforced on save |
✅ |
| Permalink rewrite rules flushed automatically on save / delete | ✅ |
Pairs with Headless Bridge — every CPT gets featured_image_url + seo for free |
✅ |
| Admin UI uses Etherlabz "Warm Sunset" brand palette | ✅ |
Translation-ready (simple-post-types text domain) |
✅ |
Requirements
| Minimum | Recommended | |
|---|---|---|
| PHP | 7.4 | 8.2+ |
| WordPress | 6.0 | latest |
| OpenSSL extension | required (encrypted API keys) | enabled by default in modern PHP |
| Sibling plugin (optional) | — | Headless Bridge for headless front-ends |
No Composer, no npm, no build step at runtime. The dev toolchain is php -l, node --check, phpunit, rsync, and zip.
Installation
From a release ZIP
- Download the latest ZIP from the Releases page.
- WP Admin → Plugins → Add New → Upload Plugin → pick the ZIP.
- Activate Etherlabz Simple Post Types.
- Click Post Types in the admin sidebar → Add post type.
From source
cd wp-content/plugins
git clone https://github.com/EtherLabZ/simple-post-types.git
Then activate as above. Nothing to install — there are no runtime dependencies.
Setup
After activation, the Post Types menu item appears in your admin sidebar (orange dashicons-screenoptions icon).
Creating a post type
Click Add post type, fill in:
| Field | Notes |
|---|---|
| Slug | 1–20 lowercase letters/digits/dashes/underscores. Must start with a letter. Reserved core slugs are blocked. Read-only after first save (renames silently orphan data). |
| Singular / plural label | What you'll see in the admin UI ("Case Study", "Case Studies"). Defaults derived from the slug. |
| REST base | Path under /wp-json/wp/v2/. Defaults to the slug. |
| Permalink slug | Path on the public site. Defaults to the slug. |
| Menu icon | Three modes — None (WP default) / Dashicon (curated catalog of ~120 icons with live filter) / Custom upload (via wp.media library). |
| Menu position | 0–100. WP positions: 5=Posts, 10=Media, 20=Pages, 25=Comments, 60=Appearance. |
| Taxonomies | Existing taxonomy slugs (e.g. category, post_tag) to attach. Comma-separated. |
| Editor support | Tick which editor features (title, editor, thumbnail, excerpt, etc.) the type should expose. |
| Visibility flags | Public, Show admin UI, Show in admin menu, Show in admin bar, Available in nav menus, Show in REST API, Exclude from front-end search. |
| Behavior flags | Enabled (register on init), Has archive, Hierarchical (page-like), Includable in WP exports, Has query_var. |
| Custom fields | See below. |
| API authentication | See below. |
Click Create post type. Rewrite rules flush automatically — /wp-json/wp/v2/{slug} is live the moment you save.
Custom fields
Each CPT can define typed metadata fields. They:
- Appear in the editor as a metabox (one input per field, with the right widget type)
- Auto-register as REST-exposed post meta — visible under
metaon/wp-json/wp/v2/{cpt}responses
Ten supported types:
| Type | UI |
|---|---|
text |
<input type="text"> |
textarea |
4-row <textarea> |
number |
<input type="number"> |
email / url / date |
matching native inputs |
select |
<select> populated from the field's options[] |
checkbox |
single boolean |
image |
preview + Choose / Clear buttons (uses wp.media) |
color |
native color picker |
Each field carries: key (snake-case slug), label, type, description (help text), default value, required flag, options (for select).
Reserved keys (id, post_title, post_content, etc.) are blocked at sanitize time.
API authentication
Each CPT can lock its REST endpoints behind a bearer token, with per-method granularity. The matrix has five rows (one per HTTP method) and three states each:
| State | Behavior |
|---|---|
| public | Anyone can call this method without a token |
| auth | Caller must send Authorization: Bearer <api_key> |
| disabled | WordPress returns 405 Method Not Allowed |
HEAD aliases to GET. OPTIONS is always public so CORS preflights aren't accidentally locked down.
Six preset chips above the matrix stamp common bundles in one click:
| Preset | What it stamps |
|---|---|
| All public | Every method public (default for new CPTs) |
| Read public, writes auth | GET public, POST/PUT/PATCH/DELETE auth |
| Submission only (public POST) | GET auth, POST public, writes disabled — perfect for contact forms |
| Read-only API (only GET enabled) | GET public, all writes disabled |
| Write public, reads auth | GET auth, all writes public |
| All auth | Every method auth |
Hand-tune any row afterwards — click any of the three state pills to flip that method's state.
The API key field auto-shows when any method is set to auth and auto-hides when none are. Click Generate to create a 64-hex-char key from CSPRNG; copy it once via the Copy button (the key is encrypted at rest and never re-shown).
Activity log
The Activity log tab tracks the most recent 100 deletions:
| Field | Notes |
|---|---|
| When | Relative timestamp (just now, 3 hr ago, 2 days ago) with full ISO-8601 on hover |
| Post type | Plural label + slug code |
| Posts | Number of posts that existed at deletion time |
| Action | kept in DB (green tag) or data purged (red tag) |
| By | WP user_login of whoever clicked Delete |
A Clear log button empties the log. Activity is local to the site — never sent anywhere.
Going headless?
Pair this with Etherlabz Headless Bridge to get featured-image URLs, normalized SEO with static-host canonical rewrite, per-post stylesheet manifests, configurable CORS, headless-mode hardening, and rebuild webhooks for Astro / Next.js / SvelteKit. Headless Bridge picks up every CPT registered with show_in_rest: true automatically.
REST surface
The plugin doesn't add its own REST routes. Every CPT you create is registered with show_in_rest: true (unless you uncheck the flag), so the standard core endpoints work:
# List all case-studies
curl 'https://your-site.com/wp-json/wp/v2/case-studies'
# Get one
curl 'https://your-site.com/wp-json/wp/v2/case-studies?slug=apollo'
# Schema
curl 'https://your-site.com/wp-json/wp/v2/case-studies/schema'
When you also have Etherlabz Headless Bridge installed, every response gets the same featured_image_url + seo fields the standard /wp/v2/posts endpoint does:
{
"id": 1234,
"slug": "apollo",
"title": { "rendered": "Apollo case study" },
"featured_image_url": {
"thumbnail": "https://...", "medium": "https://...", "large": "https://...", "full": "https://..."
},
"seo": {
"title": "Apollo case study — Etherlabz",
"description": "…",
"canonical": "https://etherlabz.co/case-studies/apollo/",
"og_image": "https://...",
"og_type": "article",
"provider": "yoast"
},
"meta": {
"client_name": "Apollo",
"industry": "Aerospace",
"hero_image": "https://etherlabz.co/...",
"is_featured": true
}
}
The meta object is populated automatically from the custom fields you defined.
API auth examples
# CPT with "Submission only" preset
# GET requires auth, POST is public, PUT/PATCH/DELETE return 405
# Public POST — anyone can submit
curl -X POST 'https://your-site/wp-json/wp/v2/contact-forms' \
-H 'Content-Type: application/json' \
-d '{"title":"Hello","status":"publish","meta":{"email":"a@b.com"}}'
# → 201 Created
# Auth-gated GET
curl 'https://your-site/wp-json/wp/v2/contact-forms'
# → 401 simple_post_types_api_key_required
curl 'https://your-site/wp-json/wp/v2/contact-forms' \
-H 'Authorization: Bearer <your-key>'
# → 200 OK with submissions
# Disabled PUT — even with auth
curl -X PUT 'https://your-site/wp-json/wp/v2/contact-forms/123' \
-H 'Authorization: Bearer <your-key>' \
-d '{"title":"x"}'
# → 405 Method Not Allowed
Architecture
A small, single-purpose codebase. Same shape as the sibling Etherlabz plugins so anyone moving between them feels at home.
- Namespaced PHP (
Etherlabz\Simple_Post_Types) withdeclare(strict_types=1). - PSR-4 autoloading via a custom 30-line loader — zero Composer runtime deps.
- Singleton bootstrap (
Main) wires every module exactly once. - Registrable interface — every module has one
register_hooks()entry point. - Strict input validation — reserved-name blocklist, slug regex, supports allow-list, field-type allow-list, scope allow-list.
- Encrypted secrets at rest via
Core/Encryption.php(AES-256-CBC keyed offAUTH_KEY/AUTH_SALT). - Per-method REST gate —
rest_authentication_errorsfilter resolves the entry'sapi_methodsmap and returns405for disabled methods or401for unauth bearer requests. - Activity-log capping — FIFO at 100 entries, auto-pruned on every write.
- Activation hook registers all CPTs once and flushes rewrite rules so the new permalinks work immediately.
simple-post-types/
├── simple-post-types.php Plugin bootstrap + header
├── uninstall.php Clean removal of every spt_* option
├── inc/
│ ├── Autoloader.php PSR-4 autoloader (no Composer)
│ ├── Main.php Singleton + module registry + activation
│ ├── Contracts/
│ │ ├── Registrable.php interface { register_hooks(): void }
│ │ └── Singleton.php shared singleton trait
│ ├── Core/
│ │ ├── Assets.php Admin CSS/JS enqueue + i18n strings + dashicon catalog
│ │ └── Encryption.php AES-256-CBC secret storage (api_key)
│ └── Modules/
│ ├── Post_Types.php CPT registration + sanitize + reserved-slug guard +
│ │ delete-with-data + activity log
│ ├── Custom_Fields.php register_post_meta + metabox render + sanitize per type
│ ├── API_Auth.php Per-method REST auth gate (public/auth/disabled)
│ ├── Ajax_Handler.php Save / delete (with optional data purge) / count posts /
│ │ generate API key / clear log — all nonced + cap-gated
│ └── Settings/
│ └── Admin_Screen.php Top-level admin menu page
├── templates/admin-screen.php Tabbed admin UI (Post types / Activity log)
├── assets/
│ ├── css/admin.css Etherlabz "Warm Sunset" palette
│ ├── css/metabox.css Editor metabox styling for custom fields
│ ├── js/admin.js Tab persistence + custom confirm modal +
│ │ icon picker + method matrix + activity-log render
│ └── js/metabox.js Image-field wp.media picker for the editor metabox
├── tests/
│ ├── bootstrap.php WP function polyfills (no Brain\Monkey, no full WP test setup)
│ └── Unit/ Post_Types, API_Auth, Deletion_Log
├── bin/
│ ├── build.sh Production ZIP builder (rsync + zip)
│ └── release.sh gh-CLI release publisher
├── .github/workflows/
│ ├── ci.yml Lint matrix + PHPUnit on every push/PR
│ └── release.yml Tag-push → ZIP → GitHub release
├── Makefile `make help` for every target
├── .distignore Files excluded from the production ZIP
└── RELEASING.md How to cut a new release
Reserved slugs
These are blocked at save time because they collide with WordPress core or break the admin in confusing ways:
post, page, attachment, revision, nav_menu_item, custom_css,
customize_changeset, oembed_cache, user_request, wp_block,
wp_template, wp_template_part, wp_global_styles, wp_navigation,
action, author, order, theme
If you legitimately need to extend one of these (e.g. post itself), do it in your theme/plugin code with add_post_type_support() — that's the right tool for that job, not a CPT registration form.
Development
make help # list every available target
make lint # php -l + node --check across the tree (no Composer/npm needed for lint)
make test # composer install + phpunit (101 tests / 240 assertions)
make check # lint + test (used in CI)
make build # produces dist/simple-post-types-<version>.zip
make release # build + create draft GitHub release (requires gh CLI)
make release-publish # build + publish a release immediately
make clean # rm -rf dist/
CI runs on every push / PR via .github/workflows/ci.yml — PHP 7.4 / 8.2 / 8.3 × Node 20 lint matrix + PHPUnit + a build smoke test.
Releases are automated on tag-push via .github/workflows/release.yml. Push a vX.Y.Z tag and the Action lints, builds, and creates the GitHub release with the ZIP attached.
See RELEASING.md for the full release procedure.
Testing
101 tests / 240 assertions, all green. PHPUnit 9.6 with WP-function polyfills (no Brain\Monkey, no full WP test setup, no extra runtime deps).
Coverage:
- Post_Types — slug validation (6 valid + 9 rejected including reserved-name + length cap), supports allowlist + dedupe, menu_icon (dashicon class / https URL / data:image/svg+xml / javascript: rejection / garbage rejection), sanitize_entry full shape (slug normalisation, label defaults, position clamping), boolean-flag regression (every flag against string
"false"/"true"/native bool/missing keys), every flag key landing on saved entry, custom-fields sanitize (reserved-key blocklist, dedupe, unknown-type collapse, select options string AND array parsing, required-flag regression, label defaults), per-method API auth (Submission-only preset, key wipe when no method needs auth, legacy scope fallback, scope mirror derived from methods) - API_Auth —
sanitize_scopeallowlist + rejection,sanitize_keybounds + alphabet,generate_keyentropy + uniqueness, full 9-cell scope×method matrix, route matching incl.productsvsproductlookalike rejection,sanitize_methods(defaults missing→public, valid passthrough, unknown→public, ignores TRACE/etc),methods_from_scopefor all four scopes + unknown-fallback,resolve_methods(explicit wins over legacy, fallback works, default all-public),entry_requires_auth(true when any auth, false when none, legacy fallback) - Deletion_Log —
record_deletionshape + user-login capture + data-purged flag + post-count clamping, log is newest-first, log caps atLOG_MAX_ENTRIES,get_logskips non-array rows in storage, returns empty for non-array option,clear_logempties the log, log-row shape completeness
Manual end-to-end verification on wp-env:
| Step | Expected |
|---|---|
| Activate plugin | Menu appears, no PHP notices |
| Add CPT via UI → save | New type appears at /wp-json/wp/v2/{slug} immediately |
Reserved CPT slug (post, wp_block, …) |
Rejected with a clear error |
| Add custom field (text) → save → edit a post | Metabox row appears in the editor |
GET /wp-json/wp/v2/{cpt} |
Includes the meta value under meta.{key} |
API auth: Submission-only preset → POST without auth |
Succeeds (201) |
API auth: Submission-only preset → GET without auth |
401 |
API auth: Submission-only preset → PUT with auth |
405 |
| Delete CPT with 0 posts | Modal shows count=0; only Keep option; one-click delete |
| Delete CPT with N posts → Keep | Posts remain in DB; CPT unregistered |
| Delete CPT with N posts → Purge | All posts permanently deleted incl. meta + terms + comments |
| Activity log tab | Shows the deletion with kept in DB or data purged tag |
| Tab state survives reload | Stays on Activity log after refresh |
| Plugin uninstall | Clears every spt_* option |
Security model
- All admin AJAX endpoints require
manage_options+ a verified nonce (spt_admin_nonce). - Strict input validation — slug regex
^[a-z][a-z0-9_-]{0,19}$, reserved-slug blocklist, supports allow-list, field-type allow-list, scope/state allow-lists. - Output escaping — admin templates pipe through
esc_html/esc_attr/esc_url. JS rendering escapes every interpolated value through a singleescapeHtml()helper. - Menu icon restricted to
dashicons-*classes /http(s)://...URLs /data:image/svg+xml;base64,...URIs only;javascript:/ftp:/ arbitrary strings dropped. - API key storage — sanitized to 32–96 chars in
[A-Za-z0-9_-], then AES-256-CBC encrypted at rest. Never round-tripped to the browser; UI showskey_set: true|false. - API key validation —
Authorization: Bearer <key>header read with case-insensitive header lookup; fallback to?api_key=query var only if the value passes the same regex; constant-time compare viahash_equals(). - Route matching is anchored — rest_base
productdoes NOT match/wp/v2/products(boundary check after the prefix). - Method-disable returns HTTP 405, not 401, so callers see the correct semantic.
- OPTIONS is always public — CORS preflights are never accidentally locked down.
- Authenticated WP users (cookie session) are never blocked by
API_Auth— this is purely an extra gate for anonymous REST clients. - Capability: registered with
capability_type: post, so editor/author/contributor roles work as expected without custom mapping. - Data deletion — the purge path uses
wp_delete_post($id, true)so post_meta, term_relationships, comments, and commentmeta are cleaned by core in batches of 200. Purge happens before unregistering the type so we never leak rows inwp_posts. - Uninstall removes every
spt_*option.
The threat model assumes site admins are trusted. The plugin only renders to users with manage_options.
License
MIT. Use it, fork it, ship it.
Built with ♥ by Mizety at Etherlabz GitHub · Issues · Releases · Sibling: Headless Bridge
Releases
24 releases. Each count is every asset in that release; expand a row for the breakdown.