This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Texty is a WordPress SMS notification plugin: PHP plugin under Texty\ (includes/) plus a React admin SPA (src/). Targets PHP 7.4+, WP 6.8+.
Pick the right skill before you start:
Skill When to invoke frontend-devAny read/edit under src/**— React, TypeScript, Tailwind, plugin-ui, charts, REST calls, routing.backend-devAny read/edit under includes/**ortexty.php— PHP classes, REST controllers, gateways, notifications, integrations, hooks, DataLayer.code-reviewReviewing code — single file, diff, branch, or full PR. Both "pr review" and "code review" map to this skill. Covers WPCS/security violations, the 5 frontend rules, backward-compatibility breaks, fatal-error risks, and PR-level merge readiness. Invoke before the first tool call so the rules are in your head as you write/review the first line, not the third.
After edits, do not auto-run verification commands (
npm run typecheck,npm run build,composer phpcs,php -l, etc.). Stop after the Edit/Write. Two contexts where they DO run automatically: (1) insidecode-review/ PR review intent — those skills' "Pre-merge gates" require running them as part of the verdict; (2) when the user explicitly asks ("check types", "build it", "run phpcs"). Outside those two, end the turn after the edit.
# Frontend (wp-scripts / webpack, output to dist/)
# All commands below are USER-TRIGGERED — never auto-run after an edit.
npm run start # dev build with watch
npm run typecheck # tsc --noEmit (strict mode, src/** only)
npm run build # production build
npm run makepot # regenerate languages/texty.pot
npm run pot2json # POT → JSON for JS i18n
# PHP
composer install # also runs Mozart in dev mode (post-install-cmd)
composer phpcs # WP coding standard (phpcs.xml) — excludes src/, dist/, vendor/
composer phpcbf # auto-fix
# Release zip
bin/build.sh # produces build/texty.zip (uses yarn under the hood)There is no PHP test suite. To verify a single PHP file: vendor/bin/phpcs -p -s includes/Path/To/File.php.
texty.php defines the Texty singleton (accessed via the global texty() helper). On plugins_loaded it instantiates three top-level objects:
Texty\Admin(only whenis_admin()) — registers the admin menu and enqueuesdist/index.js.Texty\Api— registers REST controllers under namespacetexty/v1onrest_api_init.Texty\Dispatcher— wires WP/WC/Dokan events to notification classes and registers integrations conditionally onclass_exists()checks.
The singleton lazily exposes three registries: texty()->gateways(), texty()->settings(), texty()->notifications(). Always go through these — they own the option-key constants and the registration lifecycle.
Every SMS — whether from an integration, the REST Send endpoint, or a notification — funnels through this method, which fires this exact sequence of hooks: texty_sms_to → texty_sms_message → texty_pre_send_sms (short-circuits if non-null) → texty_before_send_sms → gateway send() → texty_after_send_sms → texty_send_sms_failed (only on WP_Error). The dispatcher subscribes to texty_after_send_sms to write an SmsStat row, so any new send path automatically gets logged.
Gateways implement GatewayInterface (send, name, logo, description, get_settings, validate). Built-ins (Twilio, Vonage, Plivo, Clickatell, plus Fake when WP_DEBUG) are registered lazily inside Gateways::all(); third parties register on the texty_register_gateways action. The active gateway key lives in the texty_settings option under gateway, with credentials stored under a sub-key matching that name.
Notification (abstract) is the base for every event type. Subclasses set $id, $group (wp / wc / dokan), $default message, $default_recipients, and override replacement_keys() to map {token} → object method. The Notifications registry maps an event ID (e.g. registration, order_admin_processing) to a class. Per-notification user config (enabled, message template, recipients) lives in the texty_notifications option, keyed by ID.
Notification::send() is the standard flow (enabled check → recipient/message filters → loop). WC\Base overrides send() and uses an _texty_{id} order meta as an idempotency flag — modify cautiously; that flag is what prevents duplicate sends when an order status changes more than once.
WooCommerce and Dokan are only constructed if their main class exists. Each integration:
- Hooks
texty_register_notificationsto add its event IDs to the registry. - Subscribes to the platform's status hook (e.g.
woocommerce_order_status_changed) and dispatches the right notification class.
To add a new integration: hook texty_register_integrations (called from Dispatcher::register_integrations).
SmsStat extends WeDevs\WPKit\DataLayer\Model\BaseModel and is paired with SmsStatStore. The factory is bootstrapped in Texty::init_datalayer() with prefix texty; queries go through DataLayerFactory::make_store(SmsStat::class). The custom table {prefix}texty_sms_stat is created on activation by Install::run() via dbDelta.
Other DB locations: texty_settings (gateway + credentials), texty_notifications (per-notification config), texty_installed, texty_version, user meta texty_phone (Dokan vendors), order meta _texty_{id} (WC idempotency).
All controllers extend Api\Base, which gates access on manage_options. The Api class enumerates them in a fixed list — adding a new controller means appending to that array. Settings updates go through each gateway's validate() method before being merged into the texty_settings option, so credential validation is the gateway's responsibility, not the API's.
Only Appsero packages are namespaced into Texty\Dependencies\ via Mozart (composer.json extra.mozart). The composer post-install hook runs Mozart only in dev mode; bin/build.sh runs composer install --no-dev after copying so the production zip ships clean autoloads. Don't add new packages to the Mozart packages list unless they actually need prefixing.
- Entry
src/index.tsxmounts aThemeProvider(from@wedevs/plugin-ui) +Appinto#texty-app, then runsmenuFix('texty')to keep the WP submenu highlight in sync with the hash route. App.tsxbuilds acreateHashRouterso navigation lives entirely in#/...— the WP admin URL stays atadmin.php?page=texty. Submenus inAdmin\Menu::register_menulink directly to those hashes.- Routes are defined in
src/routing/routes.tsxand exposed throughgetRoutes()which runs them throughapplyFilters('texty.routes', routes)— extensions can add or remove routes via@wordpress/hooks. withRouterHOC insrc/routing/index.tsxinjects react-router props into either an element or a component (mirrors dokan-lite's API for extension parity).- Path alias
@/*→src/*(configured in bothwebpack.config.jsandtsconfig.json). @wedevs/plugin-uiis afile:../plugin-uilink — that sibling directory must exist fornpm installto succeed.
phpcs.xmldisables many WordPress-Core strictness rules (Yoda, escape-output, file-name) but enforces strict comparisons (Universal.Operators.StrictComparisons,WordPress.PHP.StrictInArray.MissingTrueStrict) as errors. Use===and passtruetoin_array.- TypeScript runs in strict mode; JS files in
src/are accepted (allowJs: true) but not type-checked (checkJs: false). - Text domain is
texty(enforced by phpcs).
- New class in
includes/Gateways/<Name>.phpimplementingGatewayInterface(send,name,logo,description,get_settings,validate). - Register on the
texty_register_gatewaysaction, or for a built-in append to the array inGateways::all(). - Credentials are stored as a sub-array of the
texty_settingsoption keyed by the gateway's registry key;Api\Settings::update_itemscalls yourvalidate()before persisting, so do credential checks there (return aWP_Errorto block save). - Logging is automatic —
Dispatcher::log_smslistens ontexty_after_send_smsand writes anSmsStat. Return an array containing one ofsid,message-id,message_uuid,apiMsgId, orreference_idand it will be captured as the row'sreference_id.
- New class in
includes/Notifications/<Group>/<Name>.phpextendingTexty\Notifications\Notification. Set$id,$group(wp/wc/dokan/ custom),$title,$defaultmessage template with{token}placeholders,$default_recipients, and overridereplacement_keys()to map tokens to method names on the bound object (user/order/etc.). - Register on
texty_register_notifications(the manager is passed in):$notifications->register('your_event_id', YourClass::class). - Trigger from a
Dispatchermethod or an integration: instantiate, callset_*to bind context, then->send(). - WC subclasses should extend
WC\Baseto inherit the_texty_{id}order-meta idempotency flag — don't overridesend()without re-applying that guard, or status changes will fire duplicate SMSs.
- New class in
includes/Integrations/<Name>.php. In its constructor,add_action('texty_register_notifications', ...)to add notifications, and subscribe to the platform's status hook to fan out to those notifications. - Wire it from
Dispatcher::register_integrationsbehind aclass_exists()guard on the host plugin's main class — never assume the host plugin is loaded. - Add a new group to
Notifications::get_groups()if the UI should display it as a tab.
- New controller in
includes/Api/<Name>.phpextendingApi\Base. Set$this->namespace = 'texty/v1'and$this->rest_base = '...'in the constructor; implementregister_routes()and use[$this, 'admin_permissions_check']as the permission callback. - Append the FQCN to the array in
Api::__construct— that's the only place controllers are wired in. - From the frontend, call via
apiFetch<TResponse>({ path: '/texty/v1/...', method: 'GET' }). The minimalapiFetchtypes live insrc/types/assets.d.ts; widen them if you need more options.
- Build the page in
src/pages/<name>/(lowercase, hyphenated for multi-word — e.g.src/pages/dashboard/,src/pages/quick-send/). The page entry isindex.tsx; co-locate page-only components beside it (this is the conventionpages/dashboard/follows). Reach forsrc/components/only when something is truly cross-page. - Add a
TextyRouteentry tosrc/routing/routes.tsx—pathis the hash fragment (/foo),idandtitledrive the admin submenu. - Add the matching submenu entry in
Admin\Menu::register_menu's$submenusarray — the link is built asadmin.php?page=texty#/<id>, so the submenuidmust equal the route path without the leading slash.menuFix('texty')inindex.tsxkeeps the active-submenu highlight synced with the hash.
- UI primitives: import from
@wedevs/plugin-uifirst; only fall back to@wordpress/componentsfor things plugin-ui doesn't have. plugin-ui is base-ui, not radix —Tooltipself-wraps inTooltipProvider(don't double-wrap),TooltipTrigger/DropdownMenuTriggeruse therender={<Component .../>}prop instead ofasChild. - Toasts:
import { toast } from '@wedevs/plugin-ui'(sonner under the hood) and mount one<Toaster />inApp.tsx.react-toastifyis intentionally not a dependency. - Charts: wrap recharts in plugin-ui's
ChartContainerso the theme tokens (var(--color-<key>)) flow in.rechartsis a direct dependency — importLineChart/XAxis/etc. from'recharts'. TheChartConfigtype is not re-exported by plugin-ui's index; type config locally asRecord<string, { label?: string; color?: string }>. - Phone input:
react-phone-input-2ships its own types; defaultcountry="bd"matches the dashboard mock. .tsxvs.js: webpack's resolve order is.js, .jsx, .ts, .tsx, soFoo.jsshadowsFoo.tsxin the same directory. When migrating a.jsfile to.tsx, delete the old.jsor webpack will keep picking it. New code should be.tsx.- Effects with async work: don't make the
useEffectcallbackasync(returning a promise breaks cleanup). Define an innerasyncfunction and call it; gate state updates behind acancelledflag set in the cleanup function. - i18n: every user-facing string goes through
__('...', 'texty')from@wordpress/i18n. After string changes runnpm run makepot && npm run pot2json.