$npx -y skills add Impertio-Studio/Frappe_Claude_Skill_Package --skill frappe-syntax-controllersUse when writing Python Document Controllers for ERPNext/Frappe DocTypes. Covers lifecycle hooks (validate, on_update, on_submit), controller override, submittable documents, autoname patterns, UUID naming (v16), and the flags system. Keywords: document controller, lifecycle hook
| 1 | # Frappe Syntax: Document Controllers |
| 2 | |
| 3 | Document Controllers are Python classes that define all server-side logic for a DocType. |
| 4 | EVERY DocType has exactly one controller file. The controller class extends `frappe.model.document.Document`. |
| 5 | |
| 6 | ## Quick Reference |
| 7 | |
| 8 | ```python |
| 9 | import frappe |
| 10 | from frappe import _ |
| 11 | from frappe.model.document import Document |
| 12 | |
| 13 | class SalesOrder(Document): |
| 14 | def autoname(self): |
| 15 | """Custom naming logic. Sets self.name.""" |
| 16 | self.name = f"SO-{self.customer_code}-{frappe.utils.now_datetime().year}" |
| 17 | |
| 18 | def validate(self): |
| 19 | """MAIN validation — runs on EVERY save (insert and update). |
| 20 | Changes to self ARE saved to database.""" |
| 21 | if not self.items: |
| 22 | frappe.throw(_("Items are required")) |
| 23 | self.total = sum(item.amount for item in self.items) |
| 24 | |
| 25 | def on_update(self): |
| 26 | """After save — changes to self are NOT saved. |
| 27 | Use frappe.db.set_value() for post-save field changes.""" |
| 28 | self.notify_linked_docs() |
| 29 | |
| 30 | def on_submit(self): |
| 31 | """After submit (docstatus 0 -> 1). Create ledger entries here.""" |
| 32 | self.create_gl_entries() |
| 33 | |
| 34 | def on_cancel(self): |
| 35 | """After cancel (docstatus 1 -> 2). Reverse ledger entries here.""" |
| 36 | self.reverse_gl_entries() |
| 37 | |
| 38 | @frappe.whitelist() |
| 39 | def recalculate(self): |
| 40 | """Exposed to client JS via frm.call('recalculate').""" |
| 41 | self.total = sum(item.amount for item in self.items) |
| 42 | return {"total": self.total} |
| 43 | ``` |
| 44 | |
| 45 | ### File Location and Naming |
| 46 | |
| 47 | | DocType Name | Class Name | File Path | |
| 48 | |---|---|---| |
| 49 | | Sales Order | `SalesOrder` | `selling/doctype/sales_order/sales_order.py` | |
| 50 | | My Custom Doc | `MyCustomDoc` | `module/doctype/my_custom_doc/my_custom_doc.py` | |
| 51 | |
| 52 | **Rule**: DocType name -> PascalCase class -> snake_case filename. ALWAYS match exactly. |
| 53 | |
| 54 | --- |
| 55 | |
| 56 | ## Lifecycle Hook Execution Order |
| 57 | |
| 58 | ### INSERT (new document) |
| 59 | |
| 60 | ``` |
| 61 | before_insert -> before_naming -> autoname -> before_validate -> validate |
| 62 | -> before_save -> [db_insert] -> after_insert -> on_update -> on_change |
| 63 | ``` |
| 64 | |
| 65 | ### SAVE (existing document) |
| 66 | |
| 67 | ``` |
| 68 | before_validate -> validate -> before_save -> [db_update] |
| 69 | -> on_update -> on_change |
| 70 | ``` |
| 71 | |
| 72 | ### SUBMIT (docstatus 0 -> 1) |
| 73 | |
| 74 | ``` |
| 75 | before_validate -> validate -> before_submit -> [db_update] |
| 76 | -> on_submit -> on_update -> on_change |
| 77 | ``` |
| 78 | |
| 79 | ### CANCEL (docstatus 1 -> 2) |
| 80 | |
| 81 | ``` |
| 82 | before_cancel -> [db_update] -> on_cancel -> on_change |
| 83 | ``` |
| 84 | |
| 85 | ### UPDATE AFTER SUBMIT |
| 86 | |
| 87 | ``` |
| 88 | before_update_after_submit -> [db_update] |
| 89 | -> on_update_after_submit -> on_change |
| 90 | ``` |
| 91 | |
| 92 | ### DELETE |
| 93 | |
| 94 | ``` |
| 95 | on_trash -> [db_delete] -> after_delete |
| 96 | ``` |
| 97 | |
| 98 | ### DISCARD [v15+] |
| 99 | |
| 100 | ``` |
| 101 | before_discard -> [db_set docstatus=2] -> on_discard |
| 102 | ``` |
| 103 | |
| 104 | **Complete hook reference with parameters**: See [lifecycle-methods.md](references/lifecycle-methods.md) |
| 105 | |
| 106 | --- |
| 107 | |
| 108 | ## Hook Selection Decision Tree |
| 109 | |
| 110 | ``` |
| 111 | What do you need to do? |
| 112 | | |
| 113 | +-- Validate data or calculate fields? |
| 114 | | +-- validate (changes to self ARE saved) |
| 115 | | |
| 116 | +-- Action AFTER save (emails, sync, linked docs)? |
| 117 | | +-- on_update (changes to self are NOT saved) |
| 118 | | |
| 119 | +-- Only for NEW documents? |
| 120 | | +-- after_insert (runs once on first save only) |
| 121 | | |
| 122 | +-- Custom document name? |
| 123 | | +-- autoname (set self.name) |
| 124 | | |
| 125 | +-- Before/after SUBMIT? |
| 126 | | +-- Validate before submit? -> before_submit |
| 127 | | +-- Create entries after submit? -> on_submit |
| 128 | | |
| 129 | +-- Before/after CANCEL? |
| 130 | | +-- Check linked docs? -> before_cancel |
| 131 | | +-- Reverse entries? -> on_cancel |
| 132 | | |
| 133 | +-- Cleanup before delete? |
| 134 | | +-- on_trash |
| 135 | | |
| 136 | +-- React to ANY value change (including db_set)? |
| 137 | | +-- on_change (MUST be idempotent) |
| 138 | ``` |
| 139 | |
| 140 | --- |
| 141 | |
| 142 | ## Critical Rules |
| 143 | |
| 144 | ### 1. Changes after on_update are NOT saved |
| 145 | |
| 146 | ```python |
| 147 | # WRONG - change is lost after on_update |
| 148 | def on_update(self): |
| 149 | self.status = "Completed" # NOT saved to database |
| 150 | |
| 151 | # CORRECT - use db_set or frappe.db.set_value |
| 152 | def on_update(self): |
| 153 | self.db_set("status", "Completed") |
| 154 | ``` |
| 155 | |
| 156 | ### 2. NEVER call frappe.db.commit() in controllers |
| 157 | |
| 158 | ```python |
| 159 | # WRONG - breaks Frappe transaction management |
| 160 | def validate(self): |
| 161 | frappe.db.commit() # Can cause partial updates on error |
| 162 | |
| 163 | # CORRECT - Frappe commits automatically at end of request |
| 164 | def validate(self): |
| 165 | self.update_related() # No commit needed |
| 166 | ``` |
| 167 | |
| 168 | ### 3. ALWAYS call super() when overriding |
| 169 | |
| 170 | ```python |
| 171 | # WRONG - parent validation is skipped entirely |
| 172 | def validate(self): |
| 173 | self.custom_check() |
| 174 | |
| 175 | # CORRECT - parent logic |