perfex-theme
Perfex Custom Themes & Client Area
You are a Perfex CRM theme developer. Your job is to build or fix custom client-area themes that use Perfex's asset hooks correctly, override core views without being blown away by updates, handle dark mode and RTL without FOUC, and dodge the jQuery Validate submit-button-name bug that silently breaks multi-action forms.
Perfex's client area supports custom themes under assets/themes/<theme_name>/ + application/views/themes/<theme_name>/. Themes override core views and can inject their own CSS/JS via hooks.
Theme folder layout
assets/themes/my_theme/
├── css/
│ └── theme.css
├── js/
│ └── theme.js
└── images/
└── logo.svg
application/views/themes/my_theme/
├── includes/
│ └── head.php
├── layouts/
│ └── default.php
└── (override any core view by matching path)
Activate via Setup → Settings → Customer Area Theme.
Asset loading — use the hooks, not raw <link>
// In your theme's functions.php or a companion module
hooks()->add_action('app_customers_head', 'my_theme_inject_css');
hooks()->add_action('app_customers_footer', 'my_theme_inject_js');
function my_theme_inject_css() {
$url = base_url('assets/themes/my_theme/css/theme.css');
echo '<link rel="stylesheet" href="' . $url . '?v=' . filemtime(FCPATH . 'assets/themes/my_theme/css/theme.css') . '">';
}
function my_theme_inject_js() {
$url = base_url('assets/themes/my_theme/js/theme.js');
echo '<script src="' . $url . '?v=' . filemtime(FCPATH . 'assets/themes/my_theme/js/theme.js') . '" defer></script>';
}
Why cache-bust with filemtime(): Perfex does NOT version theme assets. Without a cache-buster, browsers will serve stale CSS to signed-in users across deploys.
jQuery Validate + submit button name — the "Pay Now" bug
Perfex uses jQuery Validate on most forms. jQuery Validate's default behaviour strips the submit button's name attribute from the POST body when submitting programmatically. This breaks forms that rely on detecting which button was clicked:
<form method="post">
<button type="submit" name="pay_now" value="1">Pay Now</button>
<button type="submit" name="save_draft" value="1">Save</button>
</form>
PHP sees neither $_POST['pay_now'] nor $_POST['save_draft'] on submit.
Fix: mirror intent into a hidden input
<form method="post">
<input type="hidden" name="action" id="form_action" value="">
<button type="submit" onclick="document.getElementById('form_action').value='pay_now'">Pay Now</button>
<button type="submit" onclick="document.getElementById('form_action').value='save_draft'">Save</button>
</form>
Then check $_POST['action'] server-side. This is the pattern used in a production client theme's Pay Now fix.
Overriding a core view
Perfex resolves views by this order:
application/views/themes/<active_theme>/<path>application/views/<path>
To override the client dashboard, copy application/views/themes/perfex/clients/dashboard.php (or the default theme) to application/views/themes/my_theme/clients/dashboard.php and edit.
Don't edit core views in place. They'll be blown away on Perfex update.
Language strings in themes
Themes can't register language keys directly (no module_name.php hook point). Either:
- Package the theme with a companion module that registers keys, OR
- Use inline strings and maintain a manual i18n dict in JS:
<script> window.THEME_STRINGS = <?= json_encode([ 'save' => _l('save'), 'cancel' => _l('cancel'), // ... using core keys that already exist ]) ?>; </script>
For custom module-owned JS strings, use json_encode(_l('key')) — never raw concat — to avoid quote-escape bugs and XSS.
Dark mode pattern
Use semantic CSS custom properties, switch via [data-theme="dark"]:
:root {
--bg-primary: #fff;
--text-primary: #111;
--brand-primary: #2A5189;
}
[data-theme="dark"] {
--bg-primary: #0f1115;
--text-primary: #e8e8e8;
--brand-primary: #8eaadd; /* lift lighter in dark for contrast */
}
Apply data-theme attribute BEFORE first paint to avoid FOUC:
<head>
<script>
(function() {
var t = localStorage.getItem('my_theme_mode');
if (t) document.documentElement.setAttribute('data-theme', t);
})();
</script>
</head>
Toggle logic lives in your theme's JS; persist choice under a namespaced key like my_theme_mode.
RTL / Arabic support
Perfex supports RTL via language settings. In your theme CSS:
[dir="rtl"] .my-component {
/* flip margins, text-align */
}
Ship both LTR and RTL icon variants if your icons have directional meaning (chevrons, arrows).
Forms with Bootstrap + Perfex
Perfex ships Bootstrap 3.x in the client area and admin. For new themes, you can ship a newer Bootstrap but watch for conflicts with inline admin code. Scope newer styles by adding a wrapper class on your custom views.
Accessibility baseline
- Every
<input>needs anid+<label for="...">. aria-describedbylinking to error containers.- Decorative icons:
aria-hidden="true". - Required:
<span aria-label="required">*</span>. - Error containers:
role="alert" aria-live="polite". - Add
<main id="main-content">+ a skip link.
Overriding Perfex's Bootstrap 3 — specificity wars
Perfex ships Bootstrap 3.x across admin and client areas, plus inline styles on many core views. Your custom theme CSS will lose most specificity battles by default because:
- Perfex loads its CSS after your custom theme injection (depending on hook order), which means same-specificity selectors favor Perfex.
- Many Perfex core components use inline
style=""attributes, which only!importantoverrides. - Bootstrap 3 uses
.btn-primary,.form-controletc. — shallow single-class selectors that your:rootsemantic variables won't touch.
Strategy 1 — scope with a wrapper class (preferred)
Add a theme-root class to your overridden views and scope everything:
<!-- application/views/themes/my_theme/layouts/default.php -->
<body class="my-theme-v2">
.my-theme-v2 .btn-primary {
background: var(--brand-primary);
border-color: var(--brand-primary);
}
.my-theme-v2 .form-control {
border-radius: 8px;
border-color: #e2e8f0;
}
Two classes of specificity (.wrapper .target) beats Bootstrap's single class (.target) without needing !important. Scales to any depth.
Strategy 2 — !important with a namespaced helper class
When you can't wrap the parent (e.g., Perfex renders the <body> from core):
.my-theme-btn--override {
background: var(--brand-primary) !important;
}
Apply via override of the specific button's view. !important on a single namespaced class is safer than !important sprinkled across .btn-primary globally — the namespace makes it grep-able and removable later.
Strategy 3 — CSS layer (modern browsers only)
If you know your audience uses modern browsers, @layer lets Perfex's styles sit in one layer and yours in a higher one:
@layer perfex, mytheme;
@layer mytheme {
.btn-primary { background: var(--brand-primary); }
}
Doesn't need Perfex to opt in — your @layer mytheme wins against any unlayered Perfex CSS. Caveat: client-area IE/Safari <15.4 fall back to normal specificity. Perfex admin is power-user territory and usually Chrome/Firefox, but check your audience.
Anti-patterns
- Blanket
!importanton every rule. Quickly becomes a specificity ceiling you can't escape — next override needs!importanttoo, then the next. Scope with a wrapper class instead. - Using
#wrapperor other core-internal IDs as your specificity anchor. Perfex may rename these across versions; your CSS silently breaks. - Editing Perfex's
application/views/themes/perfex/directly. Blown away on upgrade. Copy to your theme's subtree and edit there — the override mechanism is designed for this.
Debugging checklist
| Symptom | Likely cause |
|---|---|
| Stale CSS after deploy | Missing ?v=filemtime() cache-bust |
| POST missing submit button name | jQuery Validate stripping; use hidden action input |
| View not picked up | Wrong theme active, or path case mismatch on Linux |
Language key shows raw (onboarding_save literal) |
Language file not loaded, or cached by CI loader |
| FOUC on dark mode | Theme attribute applied after first paint — move to <head> inline script |
Related skills
perfex-core-apis—app_customers_head/app_admin_headhooks are the supported asset-injection points.perfex-security—target="_blank"+rel="noopener noreferrer"and CSRF exclusions for theme-level webhook-style routes.perfex-module-dev— themes usually ship with a companion module for registering language keys and hooks.
Upstream docs
- Perfex customization guides: https://help.perfexcrm.com/category/customization/
- Applying custom CSS styles (
custom.css+ Theme Style module): https://help.perfexcrm.com/applying-custom-css-styles/ - jQuery Validate: https://jqueryvalidation.org/
More from yasserstudio/perfex-crm-skills
perfex-email
Use whenever the user is sending, rendering, or debugging transactional email in a Perfex CRM module — `$this->emails_model->send_simple_email`, `send_mail_template`, email template files under `views/emails/`, admin-recipient fallback chains (`my_module_admin_email` → `contact_form_notification_email` → `smtp_email`), retry queues with exponential backoff stored in `tbl<module>_email_retries`, or cron-driven retry processing via `after_cron_run`. Also trigger when the user says "my Perfex email isn't sending", "send_simple_email returns false", "email failed but the user saw a success page", "SMTP error in my module", "email retry queue", "why didn't my notification email go out", or "email template merge fields". Reinforces the rule that email failure must never break the user flow — always try/catch and enqueue on failure.
1perfex-security
Use whenever a Perfex CRM task touches security-sensitive code — issuing or consuming single-use tokens (password reset, magic link, confirmation), race-safe atomic UPDATE with `affected_rows()` check, handling user-controlled redirect URLs (`?next=`, `?redirect=`, `?return_to=`), rate-limiting an AJAX endpoint that leaks boolean state, cross-module model loads, logging PII, adding `target="_blank"` links, or excluding a webhook from CSRF. Also trigger when the user says "my magic link works twice", "password reset is racy", "someone can enumerate users by email", "open redirect in my module", "CSRF blocking my webhook", "rate limit this endpoint", or mentions "TOCTOU", "enumeration", `html_purify`, or `app_generate_hash()`. Every rule here exists because its absence caused a real Perfex production incident.
1perfex-core-apis
Use whenever the user is working inside a Perfex CRM codebase and touches `get_option`, `update_option`, `add_option`, `delete_option`, `hooks()`, `do_action`, `apply_filters`, `register_activation_hook`, `$this->load`, `get_instance()`, `$CI`, `db_prefix()`, auth helpers like `is_staff_logged_in` / `get_staff_user_id` / `staff_can`, or `_l()`. Also trigger when the user says "my Perfex get_option returns empty", "the hook isn't firing", "how do I hook into Perfex", "module-wide option", "Perfex helper function", "CI loader inside Perfex", or "$CI doesn't work outside a controller". This skill prevents the #1 Perfex bug — silently using `get_option('key', 'default')` which ignores the second argument.
1perfex-database
Use whenever the user writes SQL DDL for a Perfex CRM module, adds a foreign key referencing `tblcontacts`, `tblstaff`, `tblclients`, `tblinvoices`, or any `tbl*` core table, designs `tbl<module>_<entity>` schema, writes `install.php` / `uninstall.php` DDL, writes a migration or `ALTER TABLE`, or debugs "Cannot add foreign key constraint" / "incompatible" errors. Also trigger when the user says "FK won't create in Perfex", "my module's table has wrong collation", "schema in staging differs from prod", "add a column to my Perfex module table", or mentions `db_prefix()` in a DDL context, `utf8mb4_unicode_ci`, or `VARCHAR(191)` vs `VARCHAR(255)`. Prevents the UNSIGNED-INT-vs-signed-INT trap that silently drops foreign-key constraints pointing at Perfex core tables.
1perfex-module-dev
Use whenever the user is creating, modifying, or debugging a Perfex CRM module — anything under `modules/<module_name>/` including `module_name.php`, `install.php`, `uninstall.php`, controllers extending `AdminController` or `ClientsController`, models extending `App_Model`, views, language files, or menu items via `app_menu->add_sidebar_menu_item`. Also trigger when the user says "my Perfex module won't install", "activation hook not running", "the module doesn't show up in Setup", "controller returns 404", "model not loading in Perfex", "admin menu item not showing", or "build a new Perfex module from scratch". Covers module lifecycle, CI3 controller conventions, and the Linux case-sensitivity trap that silently breaks model loading on production.
1perfex-customfields
Use whenever the user is reading, writing, installing, or debugging Perfex CRM custom fields — `tblcustomfields` (definitions), `tblcustomfieldsvalues` (values keyed by `relid`), field types (`input`, `textarea`, `select`, `multiselect`, `checkbox`, `date`, `datetime`, `link`, `colorpicker`, `file`), `fieldto` values (`contacts`, `customers`, `leads`, `invoice`, `estimate`, `contracts`, `tasks`, `tickets`, etc.), `only_admin` visibility, `show_on_client_portal`, `bs_column`, the intentionally-misspelled `disalow_client_to_edit` column, or `render_custom_fields()`. Also trigger when the user says "my custom field isn't showing in the client portal", "I added a custom field in code but it doesn't appear", "custom field value not saving", "only_admin isn't respected", or "Perfex custom field types". Preserves the `disalow_client_to_edit` typo that Perfex core queries by exact name.
1