skills/source/impl/frappe-impl-controllers/SKILL.md
Use when building Document Controllers in a custom Frappe app: file creation, lifecycle hooks, validation, autoname, submittable workflows, controller override, child table controllers, flags system, migration from hooks.py and Server Scripts. Keywords: how to implement controller, which hook to use, validate vs on_update, override controller, submittable document, autoname, flags, extend_doctype_class, controller testing, child table controller, which hook to use, when does validate run, how to override save, document lifecycle.
npx skillsauth add OpenAEC-Foundation/ERPNext_Anthropic_Claude_Development_Skill_Package frappe-impl-controllersInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Step-by-step workflows for building server-side DocType logic with full Python power. For exact syntax, see frappe-syntax-controllers.
Version: v14/v15/v16 | v15+: Supports auto-generated type annotations
NEED full Python (imports, classes, generators)? → Controller
NEED external libraries (requests, pandas)? → Controller
NEED try/except with rollback? → Controller
NEED frappe.enqueue() for background jobs? → Controller
NEED to extend standard ERPNext DocType? → Controller
Quick validation without custom app? → Server Script
Simple auto-fill or notification? → Server Script
Rule: ALWAYS use Controllers when you need a custom app. ALWAYS use Server Scripts for no-code prototyping.
Step 1: Create DocType via Frappe UI or bench new-doctype
Step 2: File is auto-generated at:
apps/myapp/myapp/{module}/doctype/{doctype_name}/{doctype_name}.py
Step 3: Implement the controller class:
import frappe
from frappe import _
from frappe.model.document import Document
class MyDocType(Document):
def validate(self):
self.validate_dates()
self.calculate_totals()
def validate_dates(self):
if self.from_date and self.to_date and self.from_date > self.to_date:
frappe.throw(_("From Date cannot be after To Date"))
def calculate_totals(self):
self.total = sum(item.amount for item in self.items)
Step 4: Run bench restart (or bench watch for hot-reload in dev)
Naming convention: DocType "Sales Order" → class SalesOrder, file sales_order.py
WHAT DO YOU WANT?
├── Validate data / calculate fields before save?
│ └── validate — changes to self ARE saved
│
├── Action AFTER save (emails, linked docs, logs)?
│ └── on_update — changes to self NOT saved (use db_set)
│
├── Only for NEW documents?
│ └── after_insert
│
├── Before/after SUBMIT?
│ ├── Check before submit → before_submit
│ └── Ledger entries after → on_submit
│
├── Before/after CANCEL?
│ ├── Prevent cancel → before_cancel
│ └── Reverse entries → on_cancel
│
├── Before DELETE?
│ └── on_trash (throw to prevent)
│
├── Custom document naming?
│ └── autoname
│
└── Detect ANY change (including db_set)?
└── on_change
See references/decision-tree.md for all hooks with execution order.
| Aspect | validate | on_update |
|--------|-----------|-------------|
| When | Before DB write | After DB write |
| self.x = y saved? | YES | NO — use db_set |
| Can abort with throw? | YES | Already saved |
| get_doc_before_save() | Available | Available |
| Use for | Validation, calculations | Notifications, linked docs |
# WRONG — changes in on_update are NOT saved
def on_update(self):
self.status = "Completed" # LOST!
# CORRECT — use db_set
def on_update(self):
frappe.db.set_value(self.doctype, self.name, "status", "Completed")
def validate(self):
errors = []
if not self.items:
errors.append(_("At least one item is required"))
for item in self.items:
if item.qty <= 0:
errors.append(_("Row {0}: Qty must be positive").format(item.idx))
if self.from_date > self.to_date:
errors.append(_("From Date cannot be after To Date"))
if errors:
frappe.throw("<br>".join(errors))
def validate(self):
old = self.get_doc_before_save()
if old and old.status != self.status:
self.flags.status_changed = True
self.status_changed_on = frappe.utils.now()
def on_update(self):
if self.flags.get('status_changed'):
self.notify_status_change()
Rule: ALWAYS use self.flags to pass data between hooks. NEVER rely on external state.
from frappe.model.naming import getseries
def autoname(self):
# Format: PRJ-CUST-2025-001
code = (self.customer or "GEN")[:4].upper()
year = frappe.utils.getdate(self.start_date or frappe.utils.today()).year
prefix = f"PRJ-{code}-{year}-"
self.name = getseries(prefix, 3)
Alternative — before_naming:
def before_naming(self):
if self.is_priority:
self.naming_series = "PRIORITY-.#####"
else:
self.naming_series = "STD-.#####"
DRAFT (docstatus=0) → submit() → SUBMITTED (docstatus=1) → cancel() → CANCELLED (docstatus=2)
submit(): validate → before_submit → [DB: docstatus=1] → on_update → on_submit
cancel(): before_cancel → [DB: docstatus=2] → on_cancel
class PurchaseOrder(Document):
def validate(self):
self.validate_items()
self.calculate_totals()
def before_submit(self):
# ONLY submit-specific checks here
if self.total > 100000 and not self.manager_approval:
frappe.throw(_("Manager approval required for POs over 100,000"))
def on_submit(self):
self.update_ordered_qty()
self.create_purchase_receipt_draft()
def before_cancel(self):
if frappe.db.exists("Purchase Invoice",
{"purchase_order": self.name, "docstatus": 1}):
frappe.throw(_("Cancel linked invoices first"))
def on_cancel(self):
self.reverse_ordered_qty()
Rule: NEVER duplicate validation between validate and before_submit. validate ALWAYS runs before before_submit.
# hooks.py
override_doctype_class = {
"Sales Invoice": "myapp.overrides.CustomSalesInvoice"
}
# myapp/overrides.py
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
class CustomSalesInvoice(SalesInvoice):
def validate(self):
super().validate() # ALWAYS call parent first
self.custom_validation()
# hooks.py
doc_events = {
"Sales Invoice": {
"validate": "myapp.events.validate_sales_invoice",
}
}
# myapp/events.py
def validate_sales_invoice(doc, method=None):
if doc.grand_total < 0:
frappe.throw(_("Invalid total"))
# hooks.py
extend_doctype_class = {
"Sales Invoice": "myapp.extends.SalesInvoiceExtend"
}
# myapp/extends.py — Only methods to add/override
class SalesInvoiceExtend:
def custom_method(self):
pass
Rule: ALWAYS call super().validate() in override. NEVER skip parent methods — standard ERPNext logic depends on it.
class Quotation(Document):
@frappe.whitelist()
def apply_discount(self, discount_percent):
if discount_percent < 0 or discount_percent > 100:
frappe.throw(_("Discount must be 0-100"))
self.discount_amount = self.total * (discount_percent / 100)
self.grand_total = self.total - self.discount_amount
self.save()
return {"grand_total": self.grand_total}
Client-side call:
frm.call('apply_discount', { discount_percent: 10 }).then(r => {
frm.reload_doc();
});
# Document-level flags (built-in)
doc.flags.ignore_permissions = True # Bypass permission checks
doc.flags.ignore_validate = True # Skip validate() hook
doc.flags.ignore_mandatory = True # Skip required field check
# Custom flags for inter-hook communication
def validate(self):
if self.is_urgent:
self.flags.needs_notification = True
def on_update(self):
if self.flags.get('needs_notification'):
self.notify_team()
# tests/test_my_doctype.py
import frappe
from frappe.tests.utils import FrappeTestCase
class TestMyDocType(FrappeTestCase):
def test_validate_dates(self):
doc = frappe.get_doc({
"doctype": "My DocType",
"from_date": "2025-01-10",
"to_date": "2025-01-01" # Before from_date
})
self.assertRaises(frappe.ValidationError, doc.insert)
def test_calculate_totals(self):
doc = frappe.get_doc({
"doctype": "My DocType",
"items": [
{"item": "A", "qty": 2, "rate": 100},
{"item": "B", "qty": 3, "rate": 50}
]
})
doc.insert()
self.assertEqual(doc.total, 350)
Run: bench run-tests --module myapp.module.doctype.my_doctype.test_my_doctype
before_insert → before_naming → autoname → before_validate →
validate → before_save → [DB INSERT] → after_insert →
on_update → on_change
before_validate → validate → before_save → [DB UPDATE] →
on_update → on_change
validate → before_submit → [DB: docstatus=1] →
on_update → on_submit → on_change
| Do NOT | Do Instead |
|--------|------------|
| self.x = y in on_update | frappe.db.set_value(...) |
| self.save() in on_update | Causes infinite loop |
| frappe.db.commit() in hooks | Let framework handle |
| Heavy ops in validate | Use frappe.enqueue() in on_update |
| Skip super().validate() | ALWAYS call parent first |
| frappe.get_doc() in loops | Use frappe.get_cached_doc() |
| Hardcoded thresholds | Use Settings DocType |
See references/anti-patterns.md for complete list.
frappe-syntax-controllers — Exact hook signatures and APIfrappe-errors-controllers — Error handling patternsfrappe-impl-serverscripts — When Server Script sufficesfrappe-syntax-hooks — hooks.py configurationfrappe-core-database — frappe.db.* operationsSee references/decision-tree.md for all hooks. See references/workflows.md for extended patterns. See references/examples.md for complete working examples.
tools
Use when implementing OAuth providers, Connected Apps, Webhooks, Payment Gateways, or Data Import/Export in Frappe. Prevents authentication failures from wrong OAuth flow, missed webhook deliveries, and data corruption during bulk imports. Covers OAuth2 provider/client, Connected App DocType, Webhook DocType, Payment Gateway integration, Data Import, Data Export, frappe.integrations module. Keywords: OAuth, Connected App, Webhook, Payment Gateway, Data Import, Data Export, integration, API key, OAuth2, webhook trigger, connect to external service, OAuth setup, webhook configuration, import data, export data..
development
Use when implementing hooks.py configurations in a Frappe custom app. Covers step-by-step workflows for doc_events, scheduler_events, override/extend_doctype_class, permission hooks, extend_bootinfo, fixtures, asset injection, website hooks, and doctype_js. Prevents broken transactions, missed migrations, and multi-app conflicts. Keywords: hooks.py, doc_events, scheduler_events, override doctype,, how to add hook, when to use doc_events, scheduler setup, override existing behavior. extend doctype class, permission hook, scheduler job, fixtures, doctype_js, extend_bootinfo, website hooks.
development
Use when building a custom Frappe app from scratch. Covers bench new-app walkthrough, app structure decisions, adding DocTypes, hooks, patches, fixtures management, development workflow (bench migrate, build, clear-cache), testing, packaging, installing on another site, version management, and app dependencies for v14/v15/v16. Keywords: create custom app, new frappe app, bench new-app, app structure, module creation, doctype creation, fixtures, patches, deployment, packaging, data migration, patch file, patches.txt, migrate data between DocTypes, create new app from scratch.
tools
Use when implementing client-side form features in Frappe/ERPNext: field visibility, cascading filters, calculated fields, custom buttons, server calls, form validation, child table logic, debugging. Covers step-by-step workflows from Setup > Client Script through migration to custom app JS. Keywords: how to implement client script, form logic workflow, dynamic UI, calculate fields, frm.call, frappe.call, frappe.xcall, client script testing, field dependency, custom button, how to hide field, show field based on value, add button to form, calculate total, dynamic form.