diff --git a/docs/.anchors.json b/docs/.anchors.json
index 5cbf872e9dc4..40a3795988ee 100644
--- a/docs/.anchors.json
+++ b/docs/.anchors.json
@@ -519,6 +519,11 @@
"version-4-7-7"
]
},
+ "changelogs/v4.8.0.md": {
+ "version-480": [
+ "version-4-8-0"
+ ]
+ },
"cli/cli_commands.md": {
"codeignitercli": [
"namespace-CodeIgniter\\CLI"
@@ -537,9 +542,6 @@
],
"setpadstring-item-int-max-int-extra-2-int-indent-0-string": [
"CodeIgniter\\CLI\\BaseCommand::setPad"
- ],
- "getpadarray-pad": [
- "CodeIgniter\\CLI\\BaseCommand::getPad"
]
},
"cli/cli_controllers.md": {
@@ -572,6 +574,9 @@
"makemodel": [
"make-model"
],
+ "makerequest": [
+ "make-request"
+ ],
"makeseeder": [
"make-seeder"
],
@@ -593,13 +598,341 @@
"promptbymultiplekeys": [
"prompt-by-multiple-keys"
],
+ "select-and-multiselect": [
+ "cli-library-select"
+ ],
"color": [
"cli-library-color"
+ ],
+ "the-classes-behind-the-facade": [
+ "cli-library-classes"
+ ],
+ "codeignitercli": [
+ "namespace-CodeIgniter\\CLI"
+ ],
+ "output": [
+ "CodeIgniter\\CLI\\Output"
+ ],
+ "__constructinputoutput-io-new-inputoutput-terminal-terminal-new-terminal": [
+ "CodeIgniter\\CLI\\Output::__construct"
+ ],
+ "printstring-text-string-foreground-string-background-verbosity-verbosity-verbositynormal-void": [
+ "CodeIgniter\\CLI\\Output::print"
+ ],
+ "writestring-text-string-foreground-string-background-verbosity-verbosity-verbositynormal-void": [
+ "CodeIgniter\\CLI\\Output::write"
+ ],
+ "newlineint-num-1-verbosity-verbosity-verbositynormal-void": [
+ "CodeIgniter\\CLI\\Output::newLine"
+ ],
+ "defernewline-void": [
+ "CodeIgniter\\CLI\\Output::deferNewLine"
+ ],
+ "errorstring-text-string-foreground-light_red-string-background-void": [
+ "CodeIgniter\\CLI\\Output::error"
+ ],
+ "beepint-num-1-void": [
+ "CodeIgniter\\CLI\\Output::beep"
+ ],
+ "clearscreen-void": [
+ "CodeIgniter\\CLI\\Output::clearScreen"
+ ],
+ "getverbosity-verbosity": [
+ "CodeIgniter\\CLI\\Output::getVerbosity"
+ ],
+ "setverbosityverbosity-verbosity-void": [
+ "CodeIgniter\\CLI\\Output::setVerbosity"
+ ],
+ "isquiet-bool": [
+ "CodeIgniter\\CLI\\Output::isQuiet"
+ ],
+ "isverbose-bool": [
+ "CodeIgniter\\CLI\\Output::isVerbose"
+ ],
+ "isveryverbose-bool": [
+ "CodeIgniter\\CLI\\Output::isVeryVerbose"
+ ],
+ "isdebug-bool": [
+ "CodeIgniter\\CLI\\Output::isDebug"
+ ],
+ "colorstring-text-string-foreground-string-background-string-format-string": [
+ "CodeIgniter\\CLI\\Output::color"
+ ],
+ "wrapstring-text-int-max-0-int-padleft-0-string": [
+ "CodeIgniter\\CLI\\Output::wrap"
+ ],
+ "isdecorated-bool": [
+ "CodeIgniter\\CLI\\Output::isDecorated"
+ ],
+ "forstderr-static": [
+ "CodeIgniter\\CLI\\Output::forStderr"
+ ],
+ "getterminal-terminal": [
+ "CodeIgniter\\CLI\\Output::getTerminal"
+ ],
+ "prompt_1": [
+ "CodeIgniter\\CLI\\Prompt"
+ ],
+ "__constructoutput-output-validationinterface-validation-null-keys-null": [
+ "CodeIgniter\\CLI\\Prompt::__construct"
+ ],
+ "askstring-field-arraystringnull-options-null-arraystringnull-validation-null-string": [
+ "CodeIgniter\\CLI\\Prompt::ask"
+ ],
+ "askbykeyarraystring-text-array-options-arraystringnull-validation-null-string": [
+ "CodeIgniter\\CLI\\Prompt::askByKey"
+ ],
+ "askbymultiplekeysstring-text-array-options-array": [
+ "CodeIgniter\\CLI\\Prompt::askByMultipleKeys"
+ ],
+ "selectstring-question-array-options-intstringnull-default-null-intstring": [
+ "CodeIgniter\\CLI\\Prompt::select"
+ ],
+ "multiselectstring-question-array-options-array-default-bool-required-false-array": [
+ "CodeIgniter\\CLI\\Prompt::multiselect"
+ ],
+ "verbosity": [
+ "CodeIgniter\\CLI\\Verbosity"
+ ],
+ "terminal": [
+ "CodeIgniter\\CLI\\Terminal"
+ ],
+ "getwidthint-default-80-int": [
+ "CodeIgniter\\CLI\\Terminal::getWidth"
+ ],
+ "getheightint-default-32-int": [
+ "CodeIgniter\\CLI\\Terminal::getHeight"
+ ],
+ "generatedimensions-void": [
+ "CodeIgniter\\CLI\\Terminal::generateDimensions"
+ ],
+ "hascolorsupportstream-bool": [
+ "CodeIgniter\\CLI\\Terminal::hasColorSupport"
+ ],
+ "streamsupportsstring-function-stream-bool": [
+ "CodeIgniter\\CLI\\Terminal::streamSupports"
+ ],
+ "progressbar": [
+ "CodeIgniter\\CLI\\ProgressBar"
+ ],
+ "__constructoutput-output": [
+ "CodeIgniter\\CLI\\ProgressBar::__construct"
+ ],
+ "advancetofloatint-step-int-totalsteps-10-void": [
+ "CodeIgniter\\CLI\\ProgressBar::advanceTo"
+ ],
+ "clear-void": [
+ "CodeIgniter\\CLI\\ProgressBar::clear"
+ ],
+ "table_1": [
+ "CodeIgniter\\CLI\\Table"
+ ],
+ "__constructarray-tbody-array-thead": [
+ "CodeIgniter\\CLI\\Table::__construct"
+ ],
+ "render-string": [
+ "CodeIgniter\\CLI\\Table::render"
+ ],
+ "color_1": [
+ "CodeIgniter\\CLI\\Color"
+ ],
+ "applystring-text-string-foreground-string-background-string-format-string": [
+ "CodeIgniter\\CLI\\Color::apply"
+ ],
+ "widthstring-string-int": [
+ "CodeIgniter\\CLI\\Color::width"
+ ]
+ },
+ "cli/cli_modern_commands.md": {
+ "prompting-for-missing-arguments": [
+ "prompts-for-missing-input"
+ ],
+ "restricting-command-execution": [
+ "restricting-execution"
+ ],
+ "writing-output": [
+ "modern-commands-writing-output"
+ ],
+ "writing-at-a-verbosity": [
+ "modern-commands-verbosity"
+ ],
+ "calling-silently": [
+ "modern-commands-call-silently"
+ ],
+ "codeignitercli": [
+ "namespace-CodeIgniter\\CLI"
+ ],
+ "abstractcommand_1": [
+ "CodeIgniter\\CLI\\AbstractCommand"
+ ],
+ "getcommandrunner-commands": [
+ "CodeIgniter\\CLI\\AbstractCommand::getCommandRunner"
+ ],
+ "getname-string": [
+ "CodeIgniter\\CLI\\AbstractCommand::getName"
+ ],
+ "getdescription-string": [
+ "CodeIgniter\\CLI\\AbstractCommand::getDescription"
+ ],
+ "getgroup-string": [
+ "CodeIgniter\\CLI\\AbstractCommand::getGroup"
+ ],
+ "ishidden-bool": [
+ "CodeIgniter\\CLI\\AbstractCommand::isHidden"
+ ],
+ "isheaderless-bool": [
+ "CodeIgniter\\CLI\\AbstractCommand::isHeaderless"
+ ],
+ "getusages-array": [
+ "CodeIgniter\\CLI\\AbstractCommand::getUsages"
+ ],
+ "getargumentsdefinition-array": [
+ "CodeIgniter\\CLI\\AbstractCommand::getArgumentsDefinition"
+ ],
+ "getoptionsdefinition-array": [
+ "CodeIgniter\\CLI\\AbstractCommand::getOptionsDefinition"
+ ],
+ "getshortcuts-array": [
+ "CodeIgniter\\CLI\\AbstractCommand::getShortcuts"
+ ],
+ "getnegations-array": [
+ "CodeIgniter\\CLI\\AbstractCommand::getNegations"
+ ],
+ "addusagestring-usage-static": [
+ "CodeIgniter\\CLI\\AbstractCommand::addUsage"
+ ],
+ "addargumentargument-argument-static": [
+ "CodeIgniter\\CLI\\AbstractCommand::addArgument"
+ ],
+ "addoptionoption-option-static": [
+ "CodeIgniter\\CLI\\AbstractCommand::addOption"
+ ],
+ "renderthrowablethrowable-e-void": [
+ "CodeIgniter\\CLI\\AbstractCommand::renderThrowable"
+ ],
+ "hasargumentstring-name-bool": [
+ "CodeIgniter\\CLI\\AbstractCommand::hasArgument"
+ ],
+ "hasoptionstring-name-bool": [
+ "CodeIgniter\\CLI\\AbstractCommand::hasOption"
+ ],
+ "hasshortcutstring-shortcut-bool": [
+ "CodeIgniter\\CLI\\AbstractCommand::hasShortcut"
+ ],
+ "hasnegationstring-name-bool": [
+ "CodeIgniter\\CLI\\AbstractCommand::hasNegation"
+ ],
+ "isinteractive-bool": [
+ "CodeIgniter\\CLI\\AbstractCommand::isInteractive"
+ ],
+ "setinteractivebool-interactive-static": [
+ "CodeIgniter\\CLI\\AbstractCommand::setInteractive"
+ ],
+ "runarray-arguments-array-options-output-output-null-int": [
+ "CodeIgniter\\CLI\\AbstractCommand::run"
+ ],
+ "getoutput-output": [
+ "CodeIgniter\\CLI\\AbstractCommand::getOutput"
+ ],
+ "getprompt-prompt": [
+ "CodeIgniter\\CLI\\AbstractCommand::getPrompt"
+ ],
+ "callstring-command-array-arguments-array-options-bool-nointeractionoverride-null-int": [
+ "CodeIgniter\\CLI\\AbstractCommand::call"
+ ],
+ "callsilentlystring-command-array-arguments-array-options-bool-nointeractionoverride-true-int": [
+ "CodeIgniter\\CLI\\AbstractCommand::callSilently"
+ ],
+ "getunboundarguments-array": [
+ "CodeIgniter\\CLI\\AbstractCommand::getUnboundArguments"
+ ],
+ "getunboundargumentint-index-string": [
+ "CodeIgniter\\CLI\\AbstractCommand::getUnboundArgument"
+ ],
+ "getunboundoptions-array": [
+ "CodeIgniter\\CLI\\AbstractCommand::getUnboundOptions"
+ ],
+ "getunboundoptionstring-name-arraynull-options-null-arraystringnull": [
+ "CodeIgniter\\CLI\\AbstractCommand::getUnboundOption"
+ ],
+ "hasunboundoptionstring-name-arraynull-options-null-bool": [
+ "CodeIgniter\\CLI\\AbstractCommand::hasUnboundOption"
+ ],
+ "getvalidatedarguments-array": [
+ "CodeIgniter\\CLI\\AbstractCommand::getValidatedArguments"
+ ],
+ "getvalidatedargumentstring-name-arraystring": [
+ "CodeIgniter\\CLI\\AbstractCommand::getValidatedArgument"
+ ],
+ "getvalidatedoptions-array": [
+ "CodeIgniter\\CLI\\AbstractCommand::getValidatedOptions"
+ ],
+ "getvalidatedoptionstring-name-boolarraystringnull": [
+ "CodeIgniter\\CLI\\AbstractCommand::getValidatedOption"
+ ]
+ },
+ "cli/cli_modern_generators.md": {
+ "templates": [
+ "generator-command-templates"
+ ],
+ "codeignitercli": [
+ "namespace-CodeIgniter\\CLI"
+ ],
+ "abstractgeneratorcommand_1": [
+ "CodeIgniter\\CLI\\AbstractGeneratorCommand"
+ ],
+ "generateclass-int": [
+ "CodeIgniter\\CLI\\AbstractGeneratorCommand::generateClass"
+ ],
+ "generateviewstring-view-int": [
+ "CodeIgniter\\CLI\\AbstractGeneratorCommand::generateView"
+ ],
+ "iscodeigniternamespacedeclined-bool": [
+ "CodeIgniter\\CLI\\AbstractGeneratorCommand::isCodeIgniterNamespaceDeclined"
+ ],
+ "getreplacementsstring-class-array": [
+ "CodeIgniter\\CLI\\AbstractGeneratorCommand::getReplacements"
+ ],
+ "gettemplatedatastring-class-array": [
+ "CodeIgniter\\CLI\\AbstractGeneratorCommand::getTemplateData"
+ ],
+ "shouldappendsuffix-bool": [
+ "CodeIgniter\\CLI\\AbstractGeneratorCommand::shouldAppendSuffix"
+ ],
+ "basenamestring-filename-string": [
+ "CodeIgniter\\CLI\\AbstractGeneratorCommand::basename"
+ ],
+ "getnamespace-string": [
+ "CodeIgniter\\CLI\\AbstractGeneratorCommand::getNamespace"
+ ],
+ "buildpathstring-class-string": [
+ "CodeIgniter\\CLI\\AbstractGeneratorCommand::buildPath"
+ ],
+ "getbasepathstring-namespace-string": [
+ "CodeIgniter\\CLI\\AbstractGeneratorCommand::getBasePath"
+ ],
+ "rendertemplatearray-data-string": [
+ "CodeIgniter\\CLI\\AbstractGeneratorCommand::renderTemplate"
+ ],
+ "providegeneratoroptions-void": [
+ "CodeIgniter\\CLI\\AbstractGeneratorCommand::provideGeneratorOptions"
+ ],
+ "addnamespaceoptionstring-default-app_namespace-static": [
+ "CodeIgniter\\CLI\\AbstractGeneratorCommand::addNamespaceOption"
+ ],
+ "addsuffixoption-static": [
+ "CodeIgniter\\CLI\\AbstractGeneratorCommand::addSuffixOption"
+ ],
+ "addforceoption-static": [
+ "CodeIgniter\\CLI\\AbstractGeneratorCommand::addForceOption"
]
},
"cli/cli_request.md": {
"getoptionkey": [
"getoption-key"
+ ],
+ "getrawoptionkey": [
+ "getrawoption-key"
]
},
"cli/cli_signals.md": {
@@ -652,6 +985,11 @@
"CodeIgniter\\CLI\\SignalTrait::unregisterSignals"
]
},
+ "cli/spark_commands.md": {
+ "verbosity": [
+ "spark-verbosity"
+ ]
+ },
"concepts/autoloader.md": {
"namespaces": [
"autoloader-namespaces"
@@ -764,6 +1102,9 @@
],
"encrypt": [
"mysqli-encrypt"
+ ],
+ "timezone": [
+ "database-config-timezone"
]
},
"database/events.md": {
@@ -799,11 +1140,24 @@
"query-bindings": [
"database-queries-query-bindings"
],
+ "constraint-violation-exceptions": [
+ "database-constraint-violation-exceptions",
+ "database-unique-constraint-violation"
+ ],
+ "db-getlastexception": [
+ "database-get-last-exception"
+ ],
"close": [
"database-queries-stmt-close"
]
},
"database/query_builder.md": {
+ "builder-getcompiledselect": [
+ "query-builder-get-compiled-select"
+ ],
+ "builder-explain": [
+ "query-builder-explain"
+ ],
"select": [
"query-builder-select"
],
@@ -839,6 +1193,15 @@
"query-builder-where-subquery",
"subqueries"
],
+ "builder-wherecolumn": [
+ "query-builder-where-column"
+ ],
+ "builder-wherebetween": [
+ "query-builder-where-between"
+ ],
+ "builder-whereexists": [
+ "query-builder-where-exists"
+ ],
"1-simple-keyvalue-method_1": [
"simple-key-value-method"
],
@@ -849,12 +1212,36 @@
"query-builder-like-rawsql",
"rawsql"
],
+ "builder-likeany": [
+ "query-builder-like-any"
+ ],
+ "builder-havingbetween": [
+ "query-builder-having-between"
+ ],
"1-simple-keyvalue-method_2": [
"simple-key-value-method"
],
"2-associative-array-method_1": [
"associative-array-method"
],
+ "builder-exists": [
+ "query-builder-exists"
+ ],
+ "driver-support": [
+ "query-builder-pessimistic-locking-driver-support"
+ ],
+ "shared-lock": [
+ "query-builder-shared-lock"
+ ],
+ "lock-for-update": [
+ "query-builder-lock-for-update"
+ ],
+ "skip-locked": [
+ "query-builder-skip-locked"
+ ],
+ "no-wait": [
+ "query-builder-nowait"
+ ],
"union-queries": [
"query-builder-union"
],
@@ -894,12 +1281,33 @@
"countallreset-true": [
"CodeIgniter\\Database\\BaseBuilder::countAll"
],
+ "explainreset-true": [
+ "CodeIgniter\\Database\\BaseBuilder::explain"
+ ],
+ "existsreset-true": [
+ "CodeIgniter\\Database\\BaseBuilder::exists"
+ ],
+ "doesntexistreset-true": [
+ "CodeIgniter\\Database\\BaseBuilder::doesntExist"
+ ],
"getlimit-null-offset-null-reset-true": [
"CodeIgniter\\Database\\BaseBuilder::get"
],
"getwherewhere-null-limit-null-offset-null-reset-true": [
"CodeIgniter\\Database\\BaseBuilder::getWhere"
],
+ "lockforupdate": [
+ "CodeIgniter\\Database\\BaseBuilder::lockForUpdate"
+ ],
+ "nowait": [
+ "CodeIgniter\\Database\\BaseBuilder::nowait"
+ ],
+ "sharedlock": [
+ "CodeIgniter\\Database\\BaseBuilder::sharedLock"
+ ],
+ "skiplocked": [
+ "CodeIgniter\\Database\\BaseBuilder::skipLocked"
+ ],
"selectselect-escape-null": [
"CodeIgniter\\Database\\BaseBuilder::select"
],
@@ -942,6 +1350,36 @@
"orwherekey-value-null-escape-null": [
"CodeIgniter\\Database\\BaseBuilder::orWhere"
],
+ "wherecolumnfirst-second-escape-null": [
+ "CodeIgniter\\Database\\BaseBuilder::whereColumn"
+ ],
+ "orwherecolumnfirst-second-escape-null": [
+ "CodeIgniter\\Database\\BaseBuilder::orWhereColumn"
+ ],
+ "wherebetweenstring-key-null-array-values-null-bool-escape-null": [
+ "CodeIgniter\\Database\\BaseBuilder::whereBetween"
+ ],
+ "orwherebetweenstring-key-null-array-values-null-bool-escape-null": [
+ "CodeIgniter\\Database\\BaseBuilder::orWhereBetween"
+ ],
+ "wherenotbetweenstring-key-null-array-values-null-bool-escape-null": [
+ "CodeIgniter\\Database\\BaseBuilder::whereNotBetween"
+ ],
+ "orwherenotbetweenstring-key-null-array-values-null-bool-escape-null": [
+ "CodeIgniter\\Database\\BaseBuilder::orWhereNotBetween"
+ ],
+ "whereexistssubquery": [
+ "CodeIgniter\\Database\\BaseBuilder::whereExists"
+ ],
+ "orwhereexistssubquery": [
+ "CodeIgniter\\Database\\BaseBuilder::orWhereExists"
+ ],
+ "wherenotexistssubquery": [
+ "CodeIgniter\\Database\\BaseBuilder::whereNotExists"
+ ],
+ "orwherenotexistssubquery": [
+ "CodeIgniter\\Database\\BaseBuilder::orWhereNotExists"
+ ],
"orwhereinkey-null-values-null-escape-null": [
"CodeIgniter\\Database\\BaseBuilder::orWhereIn"
],
@@ -972,9 +1410,15 @@
"likefield-match-side-both-escape-null-insensitivesearch-false": [
"CodeIgniter\\Database\\BaseBuilder::like"
],
+ "likeanyfields-match-side-both-escape-null-insensitivesearch-false": [
+ "CodeIgniter\\Database\\BaseBuilder::likeAny"
+ ],
"orlikefield-match-side-both-escape-null-insensitivesearch-false": [
"CodeIgniter\\Database\\BaseBuilder::orLike"
],
+ "orlikeanyfields-match-side-both-escape-null-insensitivesearch-false": [
+ "CodeIgniter\\Database\\BaseBuilder::orLikeAny"
+ ],
"notlikefield-match-side-both-escape-null-insensitivesearch-false": [
"CodeIgniter\\Database\\BaseBuilder::notLike"
],
@@ -987,6 +1431,18 @@
"orhavingkey-value-null-escape-null": [
"CodeIgniter\\Database\\BaseBuilder::orHaving"
],
+ "havingbetweenstring-key-null-array-values-null-bool-escape-null": [
+ "CodeIgniter\\Database\\BaseBuilder::havingBetween"
+ ],
+ "orhavingbetweenstring-key-null-array-values-null-bool-escape-null": [
+ "CodeIgniter\\Database\\BaseBuilder::orHavingBetween"
+ ],
+ "havingnotbetweenstring-key-null-array-values-null-bool-escape-null": [
+ "CodeIgniter\\Database\\BaseBuilder::havingNotBetween"
+ ],
+ "orhavingnotbetweenstring-key-null-array-values-null-bool-escape-null": [
+ "CodeIgniter\\Database\\BaseBuilder::orHavingNotBetween"
+ ],
"orhavinginkey-null-values-null-escape-null": [
"CodeIgniter\\Database\\BaseBuilder::orHavingIn"
],
@@ -1092,9 +1548,15 @@
"incrementcolumn-value-1": [
"CodeIgniter\\Database\\BaseBuilder::increment"
],
+ "incrementmanycolumns-value-1": [
+ "CodeIgniter\\Database\\BaseBuilder::incrementMany"
+ ],
"decrementcolumn-value-1": [
"CodeIgniter\\Database\\BaseBuilder::decrement"
],
+ "decrementmanycolumns-value-1": [
+ "CodeIgniter\\Database\\BaseBuilder::decrementMany"
+ ],
"truncate": [
"CodeIgniter\\Database\\BaseBuilder::truncate"
],
@@ -1186,6 +1648,12 @@
"codeigniters-approach-to-transactions": [
"codeigniter-s-approach-to-transactions"
],
+ "running-transactions-with-a-closure": [
+ "transactions-closure"
+ ],
+ "classifying-retryable-transaction-failures": [
+ "transactions-retryable-exceptions"
+ ],
"resetting-transaction-status": [
"transactions-resetting-transaction-status"
],
@@ -1195,6 +1663,12 @@
"throwing-exceptions": [
"transactions-throwing-exceptions"
],
+ "running-code-after-commit-or-rollback": [
+ "transactions-transaction-callbacks"
+ ],
+ "checking-transaction-state": [
+ "transactions-checking-transaction-state"
+ ],
"running-transactions-manually": [
"transactions-manual-transactions"
]
@@ -1480,11 +1954,79 @@
"environment-variables-as-replacements-for-data": [
"env-var-replacements-for-data"
],
+ "controlling-how-values-are-merged": [
+ "registrar-merge-directives"
+ ],
"configcheck": [
"spark-config-check",
"config-check"
]
},
+ "general/context.md": {
+ "codeignitercontext": [
+ "namespace-CodeIgniter\\Context"
+ ],
+ "context_1": [
+ "CodeIgniter\\Context\\Context"
+ ],
+ "setkey-value-null": [
+ "CodeIgniter\\Context\\Context::set"
+ ],
+ "sethiddenkey-value-null": [
+ "CodeIgniter\\Context\\Context::setHidden"
+ ],
+ "getkey-default-null": [
+ "CodeIgniter\\Context\\Context::get"
+ ],
+ "gethiddenkey-default-null": [
+ "CodeIgniter\\Context\\Context::getHidden"
+ ],
+ "getonlykeys": [
+ "CodeIgniter\\Context\\Context::getOnly"
+ ],
+ "getonlyhiddenkeys": [
+ "CodeIgniter\\Context\\Context::getOnlyHidden"
+ ],
+ "getexceptkeys": [
+ "CodeIgniter\\Context\\Context::getExcept"
+ ],
+ "getexcepthiddenkeys": [
+ "CodeIgniter\\Context\\Context::getExceptHidden"
+ ],
+ "getall": [
+ "CodeIgniter\\Context\\Context::getAll"
+ ],
+ "getallhidden": [
+ "CodeIgniter\\Context\\Context::getAllHidden"
+ ],
+ "haskey": [
+ "CodeIgniter\\Context\\Context::has"
+ ],
+ "hashiddenkey": [
+ "CodeIgniter\\Context\\Context::hasHidden"
+ ],
+ "missingkey": [
+ "CodeIgniter\\Context\\Context::missing"
+ ],
+ "missinghiddenkey": [
+ "CodeIgniter\\Context\\Context::missingHidden"
+ ],
+ "removekey": [
+ "CodeIgniter\\Context\\Context::remove"
+ ],
+ "removehiddenkey": [
+ "CodeIgniter\\Context\\Context::removeHidden"
+ ],
+ "clear": [
+ "CodeIgniter\\Context\\Context::clear"
+ ],
+ "clearhidden": [
+ "CodeIgniter\\Context\\Context::clearHidden"
+ ],
+ "clearall": [
+ "CodeIgniter\\Context\\Context::clearAll"
+ ]
+ },
"general/environments.md": {
"the-environment-constant": [
"environment-constant"
@@ -1494,6 +2036,9 @@
],
"nginx": [
"environment-nginx"
+ ],
+ "the-environment-service": [
+ "environment-detector-service"
]
},
"general/errors.md": {
@@ -1512,6 +2057,15 @@
"general/logging.md": {
"configuration": [
"logging-configuration"
+ ],
+ "stopping-the-handler-chain-on-failure": [
+ "logging-stop-chain-on-failure"
+ ],
+ "global-context-logging": [
+ "logging-global-context"
+ ],
+ "per-call-context-logging": [
+ "logging-per-call-context"
]
},
"general/managing_apps.md": {
@@ -1614,9 +2168,24 @@
]
},
"helpers/array_helper.md": {
- "dot_array_searchstring-search-array-values": [
+ "dot_array_searchstring-search-arrayobject-values": [
"dot_array_search"
],
+ "dot_array_hasstring-search-arrayobject-values-bool": [
+ "dot_array_has"
+ ],
+ "dot_array_setarray-array-string-search-mixed-value-void": [
+ "dot_array_set"
+ ],
+ "dot_array_unsetarray-array-string-search-bool": [
+ "dot_array_unset"
+ ],
+ "dot_array_onlyarrayobject-array-arraystring-indexes-array": [
+ "dot_array_only"
+ ],
+ "dot_array_exceptarrayobject-array-arraystring-indexes-array": [
+ "dot_array_except"
+ ],
"array_deep_searchkey-array-array": [
"array_deep_search"
],
@@ -2063,6 +2632,20 @@
"filtercheck": [
"spark-filter-check",
"filter-check"
+ ],
+ "request-id": [
+ "requestid"
+ ]
+ },
+ "incoming/form_requests.md": {
+ "_remap-methods": [
+ "remap-methods"
+ ],
+ "customizing-the-data-source": [
+ "form-request-validation-data"
+ ],
+ "flashing-normalized-input": [
+ "form-request-flash-normalized"
]
},
"incoming/incomingrequest.md": {
@@ -2072,10 +2655,13 @@
"getting-data": [
"incomingrequest-getting-data"
],
+ "typed-request-input": [
+ "incomingrequest-typed-request-input"
+ ],
"getting-json-data": [
"incomingrequest-getting-json-data"
],
- "retrieving-raw-data-put-patch-delete": [
+ "retrieving-raw-data-put-patch-delete-query": [
"incomingrequest-retrieving-raw-data"
],
"filtering-input-data": [
@@ -2102,6 +2688,9 @@
"getgetindex-null-filter-null-flags-null": [
"CodeIgniter\\HTTP\\IncomingRequest::getGet"
],
+ "input": [
+ "CodeIgniter\\HTTP\\IncomingRequest::input"
+ ],
"getpostindex-null-filter-null-flags-null": [
"CodeIgniter\\HTTP\\IncomingRequest::getPost"
],
@@ -2187,9 +2776,6 @@
"getipaddress": [
"CodeIgniter\\HTTP\\Request::getIPAddress"
],
- "isvalidipip-which": [
- "CodeIgniter\\HTTP\\Request::isValidIP"
- ],
"getmethod": [
"CodeIgniter\\HTTP\\Request::getMethod"
],
@@ -2202,9 +2788,6 @@
"getserverindex-null-filter-null-flags-null": [
"CodeIgniter\\HTTP\\Request::getServer"
],
- "getenvindex-null-filter-null-flags-null": [
- "CodeIgniter\\HTTP\\Request::getEnv"
- ],
"setglobalmethod-value": [
"CodeIgniter\\HTTP\\Request::setGlobal"
],
@@ -2230,6 +2813,9 @@
"the-behavior-of-any": [
"routing-placeholder-any"
],
+ "sample-values-for-spark-routes": [
+ "placeholder-samples-for-spark-routes"
+ ],
"named-routes": [
"using-named-routes"
],
@@ -2843,6 +3429,26 @@
"upgrading-from-4-7-6-to-4-7-7"
]
},
+ "installation/upgrade_480.md": {
+ "upgrading-from-47x-to-480": [
+ "upgrading-from-4-7-x-to-4-8-0"
+ ],
+ "query-builder-bindings": [
+ "upgrade-480-query-builder-bindings"
+ ],
+ "cli-error-output": [
+ "upgrade-480-cli-error-output"
+ ],
+ "cli-output-classes": [
+ "upgrade-480-cli-output-classes"
+ ],
+ "cli-prompt": [
+ "upgrade-480-cli-prompt"
+ ],
+ "config-environment-lookups": [
+ "upgrade-480-config-env-lookups"
+ ]
+ },
"installation/upgrade_4xx.md": {
"upgrading-from-3x-to-4x": [
"upgrading-from-3-x-to-4-x"
@@ -3032,7 +3638,7 @@
"getkey-mixed": [
"CodeIgniter\\Cache\\CacheInterface::get"
],
- "rememberstring-key-int-ttl-closure-callback": [
+ "rememberstring-key-callableint-ttl-closure-callback": [
"CodeIgniter\\Cache\\CacheInterface::remember"
],
"savestring-key-data-int-ttl-60": [
@@ -3260,6 +3866,9 @@
"proxy": [
"curlrequest-request-options-proxy"
],
+ "retry": [
+ "curlrequest-request-options-retry"
+ ],
"user_agent": [
"user-agent"
],
@@ -3333,6 +3942,10 @@
"configuration-to-maintain-compatibility-with-ci3": [
"encryption-compatible-with-ci3"
],
+ "rotating-with-the-keyrotate-command": [
+ "spark-key-rotate",
+ "rotating-with-the-key-rotate-command"
+ ],
"codeigniterencryption": [
"namespace-CodeIgniter\\Encryption"
],
@@ -3393,6 +4006,47 @@
"file-get-size-by-metric-unit"
]
},
+ "libraries/locks.md": {
+ "codeigniterlock": [
+ "namespace-CodeIgniter\\Lock"
+ ],
+ "lockmanager": [
+ "CodeIgniter\\Lock\\LockManager"
+ ],
+ "createstring-name-int-ttl-300-string-owner-null": [
+ "CodeIgniter\\Lock\\LockManager::create"
+ ],
+ "restorestring-name-string-owner-int-ttl-300": [
+ "CodeIgniter\\Lock\\LockManager::restore"
+ ],
+ "lockinterface": [
+ "CodeIgniter\\Lock\\LockInterface"
+ ],
+ "acquire": [
+ "CodeIgniter\\Lock\\LockInterface::acquire"
+ ],
+ "blockint-seconds": [
+ "CodeIgniter\\Lock\\LockInterface::block"
+ ],
+ "runclosure-callback-int-waitseconds-0": [
+ "CodeIgniter\\Lock\\LockInterface::run"
+ ],
+ "release": [
+ "CodeIgniter\\Lock\\LockInterface::release"
+ ],
+ "forcerelease": [
+ "CodeIgniter\\Lock\\LockInterface::forceRelease"
+ ],
+ "refreshint-ttl-null": [
+ "CodeIgniter\\Lock\\LockInterface::refresh"
+ ],
+ "isacquired": [
+ "CodeIgniter\\Lock\\LockInterface::isAcquired"
+ ],
+ "owner": [
+ "CodeIgniter\\Lock\\LockInterface::owner"
+ ]
+ },
"libraries/publisher.md": {
"library-reference": [
"reference"
@@ -3438,8 +4092,14 @@
"cross-site-request-forgery-csrf": [
"cross-site-request-forgery"
],
+ "fetch-metadata-csrf-protection": [
+ "csrf-fetch-metadata"
+ ],
"redirection-on-failure": [
"csrf-redirection-on-failure"
+ ],
+ "html-forms": [
+ "csrf-exclude-uris"
]
},
"libraries/sessions.md": {
@@ -3480,6 +4140,15 @@
"todatetimestring": [
"time-todatetimestring"
],
+ "between": [
+ "time-comparing-two-times-between"
+ ],
+ "min": [
+ "time-comparing-two-times-min"
+ ],
+ "max": [
+ "time-comparing-two-times-max"
+ ],
"ispast": [
"time-comparing-two-times-ispast"
],
@@ -3574,9 +4243,15 @@
"withrequest": [
"validation-withrequest"
],
+ "running-validation": [
+ "validation-running"
+ ],
"getting-validated-data": [
"validation-getting-validated-data"
],
+ "typed-validated-input": [
+ "validation-validated-input"
+ ],
"saving-sets-of-validation-rules-to-the-config-file": [
"saving-validation-rules-to-config-file"
],
@@ -3618,6 +4293,9 @@
],
"arrayjson-casting": [
"array-json-casting"
+ ],
+ "encrypted-casting": [
+ "entities-encrypted-casting"
]
},
"models/model.md": {
@@ -3633,12 +4311,21 @@
"allowedfields": [
"model-allowed-fields"
],
+ "insertonlyfields": [
+ "model-insert-only-fields"
+ ],
+ "throwondisallowedfields": [
+ "model-throw-on-disallowed-fields"
+ ],
"updateonlychanged": [
"model-update-only-changed"
],
"cleanvalidationrules": [
"clean-validation-rules"
],
+ "encrypted": [
+ "model-field-casting-encrypted"
+ ],
"allowemptyinserts_1": [
"model-allow-empty-inserts"
],
@@ -3648,6 +4335,12 @@
"save": [
"model-save"
],
+ "firstorinsert": [
+ "model-first-or-insert"
+ ],
+ "withlockedrow": [
+ "model-with-locked-row"
+ ],
"saving-dates": [
"model-saving-dates"
],
@@ -3675,6 +4368,9 @@
"getting-validation-errors": [
"model-getting-validation-errors"
],
+ "protecting-fields": [
+ "model-protecting-fields"
+ ],
"getting-query-builder-for-the-models-table": [
"getting-query-builder-for-the-model-s-table"
],
@@ -3739,12 +4435,18 @@
"api-resources": [
"api-transformers"
],
+ "including-related-resources": [
+ "api-transformers-includes"
+ ],
"codeigniterapi": [
"namespace-CodeIgniter\\API"
],
"basetransformer": [
"CodeIgniter\\API\\BaseTransformer"
],
+ "resourcetype": [
+ "CodeIgniter\\API\\BaseTransformer::$$resourceType"
+ ],
"__constructincomingrequest-request-null": [
"CodeIgniter\\API\\BaseTransformer::__construct"
],
@@ -3777,11 +4479,17 @@
]
},
"outgoing/csp.md": {
+ "enable-or-disable-csp-at-runtime": [
+ "csp-enable-disable"
+ ],
"clear-directives": [
"csp-clear-directives"
],
"using-functions": [
"csp-using-functions"
+ ],
+ "control-nonce-generation": [
+ "csp-control-nonce-generation"
]
},
"outgoing/localization.md": {
@@ -3799,6 +4507,15 @@
"redirect-status-code": [
"response-redirect-status-code"
],
+ "server-sent-events-sse": [
+ "server-sent-events"
+ ],
+ "development-server-limitations": [
+ "sse-development-server"
+ ],
+ "production-considerations": [
+ "sse-production"
+ ],
"codeigniterhttp": [
"namespace-CodeIgniter\\HTTP"
],
@@ -4093,6 +4810,9 @@
"assertcloseenoughstringexpected-actual-message-tolerance-1": [
"assertcloseenoughstring-expected-actual-message-tolerance-1"
],
+ "assertsamesqlexpected-actual-message": [
+ "assertsamesql-expected-actual-message"
+ ],
"accessing-protectedprivate-properties": [
"accessing-protected-private-properties"
],
diff --git a/docs/.nav.yml b/docs/.nav.yml
index ab7bb8d95e85..2247f0d7eda3 100644
--- a/docs/.nav.yml
+++ b/docs/.nav.yml
@@ -16,6 +16,7 @@ nav:
- installation/deployment.md
- "Change Logs":
- changelogs/index.md
+ - changelogs/v4.8.0.md
- changelogs/v4.7.7.md
- changelogs/v4.7.6.md
- changelogs/v4.7.5.md
@@ -100,6 +101,7 @@ nav:
- "Upgrading From a Previous Version":
- installation/upgrading.md
- installation/backward_compatibility_notes.md
+ - installation/upgrade_480.md
- installation/upgrade_477.md
- installation/upgrade_476.md
- installation/upgrade_475.md
@@ -217,6 +219,7 @@ nav:
- general/logging.md
- general/errors.md
- general/caching.md
+ - general/context.md
- general/ajax.md
- general/modules.md
- general/managing_apps.md
@@ -231,6 +234,7 @@ nav:
- incoming/message.md
- incoming/request.md
- incoming/incomingrequest.md
+ - incoming/form_requests.md
- incoming/content_negotiation.md
- incoming/methodspoofing.md
- incoming/restful.md
@@ -285,6 +289,7 @@ nav:
- libraries/file_collections.md
- libraries/honeypot.md
- libraries/images.md
+ - libraries/locks.md
- libraries/pagination.md
- libraries/publisher.md
- libraries/security.md
@@ -330,7 +335,9 @@ nav:
- cli/cli_controllers.md
- cli/spark_commands.md
- cli/cli_commands.md
+ - cli/cli_modern_commands.md
- cli/cli_generators.md
+ - cli/cli_modern_generators.md
- cli/cli_library.md
- cli/cli_signals.md
- cli/cli_request.md
diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md
index 3f8f322cf192..3976cc50a556 100644
--- a/docs/changelogs/index.md
+++ b/docs/changelogs/index.md
@@ -6,6 +6,7 @@
See all the changes.
+- [Version 4.8.0](v4.8.0.md)
- [Version 4.7.7](v4.7.7.md)
- [Version 4.7.6](v4.7.6.md)
- [Version 4.7.5](v4.7.5.md)
diff --git a/docs/changelogs/v4.3.0.md b/docs/changelogs/v4.3.0.md
index 59ef09604f60..044dab77eff9 100644
--- a/docs/changelogs/v4.3.0.md
+++ b/docs/changelogs/v4.3.0.md
@@ -250,7 +250,7 @@ The following items are affected:
- **Config:** Now you can specify Composer packages to auto-discover manually. See [Code Modules](../general/modules.md#specify-composer-packages).
- **Config:** Added `Config\Session` class to handle session configuration.
- **Debug:** Kint has been updated to 5.0.2.
-- **Request:** Added new `$request->getRawInputVar()` method to return a specified variable from the raw stream. See [Retrieving Raw data](../incoming/incomingrequest.md#retrieving-raw-data-put-patch-delete).
+- **Request:** Added new `$request->getRawInputVar()` method to return a specified variable from the raw stream. See [Retrieving Raw data](../incoming/incomingrequest.md#retrieving-raw-data-put-patch-delete-query).
- **Request:** Added new `$request->is()` method to query the request type. See [Determining Request Type](../incoming/incomingrequest.md#is).
## Message Changes
diff --git a/docs/changelogs/v4.8.0.md b/docs/changelogs/v4.8.0.md
new file mode 100644
index 000000000000..74ab79e58e5d
--- /dev/null
+++ b/docs/changelogs/v4.8.0.md
@@ -0,0 +1,388 @@
+# Version 4.8.0
+
+Release Date: Unreleased
+
+**4.8.0 release of CodeIgniter4**
+
+## Highlights
+
+- TBD
+
+## BREAKING
+
+### Behavior Changes
+
+- The static `Boot::initializeConsole()` method no longer handles the display of the console header. This is now handled within `Console::run()`. If you have overridden `Boot::initializeConsole()`, you should remove any code related to displaying the console header, as this is now the responsibility of the `Console` class.
+- **CLI:** Uncaught exceptions in `spark`, and errors shown with `BaseCommand::showError()` or `AbstractCommand::renderThrowable()` (as `migrate` and `db:seed` do), are now printed to STDERR instead of STDOUT. The exit code is unchanged. See [CLI Error Output](../installation/upgrade_480.md#cli-error-output).
+- **CLI:** `CLI::beep()` now writes through the `InputOutput` transport instead of `echo`, so it is discarded by `callSilently()`, captured by `MockInputOutput`, and no longer seen by PHP output buffering on the command line.
+- **CLI:** `CLI::print()` of text ending in a line feed, and a progress bar drawn with `CLI::showProgress()`, no longer cause a blank line before the next `CLI::write()`, since the cursor already sits at the start of a line. `CLI::init()` now also resets that cursor state, the progress bar, and the cached terminal dimensions.
+- **CLI:** `CLI::showProgress()` now draws a full bar at 100% for a step beyond the total, instead of throwing a `ValueError`.
+- **CLI:** `CLI::error()` no longer leaves color support disabled for STDOUT when it is given an invalid color name, and `CLI::write()`, `CLI::print()`, and `CLI::color()` now accept an empty foreground with a background instead of throwing.
+- **CLI:** An empty `NO_COLOR` no longer turns color off, following . Color detection now turns color off when `TERM` is `dumb`, and on Windows when the stream is not a terminal (outside MSYS2's `MINGW32` and `MINGW64`). On Windows without VT100 support, any `TERM` starting with `xterm`, `screen`, `vt100`, `vt220`, `putty`, `rxvt`, `ansi`, `cygwin` or `linux`, or ending in `-256color`, now enables color, where previously only `xterm` did. Coloring text that already contains a colored segment now keeps that segment whatever its color, where previously only the dim colors (`\033[0;...m`) were kept and a bright or background-only segment lost the color of the text after it.
+- **CLI:** The output state moved out of the static `CLI` properties. A subclass of `CLI` that overrides `fwrite()` or `color()`, or reads `$lastWrite`, `$isColored`, `$io`, `$height`, `$width`, or the color tables, no longer affects or observes the output. See [CLI Output Classes](../installation/upgrade_480.md#cli-output-classes).
+- **CLI:** The prompt methods moved to `Prompt`. A subclass of `CLI` that overrides `validate()` no longer affects the prompts, and an override of `prompt()` no longer affects `promptByKey()` or `promptByMultipleKeys()`. Passing `prompt()` options or rules, or a `promptByKey()` text, of another type than `string`, `array`, or `null` now throws a `TypeError` instead of an `InvalidArgumentException`. See [CLI Prompt](../installation/upgrade_480.md#cli-prompt).
+- **Commands:** The `-h` option of the `routes` command, which was previously mapped to `--sort-by-handler`, is now removed. Use `--sort-by-handler` instead to sort the routes by handler when running the `routes` command.
+- **Commands:** The `filter:check` command now requires the HTTP method argument to be uppercase (e.g., `spark filter:check GET /` instead of `spark filter:check get /`).
+- **Commands:** Several built-in commands have been migrated from `BaseCommand` to the modern `AbstractCommand` style. Applications that extend a built-in command to override behavior may need to re-implement against the modern API (`configure()` + `execute()` and the `#[Command]` attribute) once the class it extends is migrated, or, preferably, compose instead of extending. Invocations on the command line are unaffected.
+- **Commands:** Generator commands migrated to `AbstractGeneratorCommand` now return `EXIT_ERROR` (previously `EXIT_SUCCESS`) when the target file exists without `--force`, when the namespace is not defined, or when the name does not form a valid class name. The `CodeIgniter` namespace confirmation is no longer prompted on non-interactive runs, and `make:cell` and `make:model --return entity` now ask it only once.
+- **Commands:** The `make:command` command now generates modern commands: a `basic` command extends `AbstractCommand` with the `#[Command]` attribute, and a `generator` command extends `AbstractGeneratorCommand` with the `#[GeneratorCommand]` attribute.
+- **Commands:** The `make:migration` command now rejects `--force` as an unknown option. It was previously accepted but ignored, since the timestamped file name only collides with an existing file when the same name is generated twice within one second.
+- **Commands:** The success and error messages from `debugbar:clear`, `cache:clear`, and `cache:info` now include the affected path or cache driver/handler so the user can see which resource was acted on (or rejected). Scripts asserting on the prior literal text will need to be updated.
+- **Commands:** Declining the `key:generate` overwrite prompt interactively now returns `EXIT_SUCCESS` instead of `EXIT_ERROR`. Output messages were also reworded; CI/automation that branches on the exit code or greps the previous wording will need updating.
+- **Commands:** The `migrate:rollback` command no longer accepts the undocumented `-g` (database group) option. It never had any effect, since `MigrationRunner::regress()` ignores the group, and the modern command pipeline now rejects unknown options. Remove `-g` from any `migrate:rollback` invocation.
+- **Commands:** The `logs:clear` command now returns `EXIT_SUCCESS` (previously `EXIT_ERROR`) when the user declines the interactive confirmation prompt, since user-initiated cancellation is not a failure. Output messages have also been reworded to distinguish cancellation (interactive `n`) from abort (non-interactive without `--force`), and the resolved log directory path is now included in the prompt, success, and failure messages.
+- **Commands:** The `db:table` command now returns `EXIT_SUCCESS` from `db:table --show` (previously `EXIT_ERROR`), and reports an error instead of prompting when run non-interactively (`--no-interaction` or piped input) without a valid table. CI/automation that branches on the `--show` exit code will need updating.
+- **Commands:** The `db:table` output was reworded: the data and metadata column headers are now capitalized (e.g. `Id`, `Created_at`), the connection summary headers read `Hostname`, `Database`, `Username`, `DB Driver`, `DB Prefix`, and `Port`, and the section titles changed (e.g. `Data of "users" table:`). Scripts that grep the previous output will need updating.
+- **Commands:** The `worker:uninstall` command now returns `EXIT_SUCCESS` (previously `EXIT_ERROR`) when the user declines the interactive confirmation prompt, since user-initiated cancellation is not a failure. In non-interactive mode without `--force` it now aborts with `EXIT_ERROR` instead of prompting, and the redundant `Uninstalling FrankenPHP Worker Mode` header was dropped. Scripts that branch on the previous exit code or wording will need updating.
+- **Commands:** The `phpini:check` command's invalid-argument error now prints only `You must specify a correct argument.`. The usage example and the argument listing moved to `phpini:check --help`, where the argument is now named `section`. Scripts that grep the previous output will need updating.
+- **Commands:** The prompts that pick from fixed options (`db:table`, `db:create`, `key:generate`, `key:rotate`, `make:command`, `make:controller`, and `make:model`) now use `Prompt::select()`, so where the arrow-key menu is unavailable, as on Windows, the answer is the option's number instead of its value. Option values such as `--prefix` still take the value.
+- **Config:** `CodeIgniter\Config\BaseService` is now `abstract`. It is intended to be extended (as `Config\Services` does) and accessed statically; code that instantiated it directly with `new BaseService()` will now throw an `Error`.
+- **Config:** `BaseConfig` now scans the environment once instead of checking every property against it, which makes building config classes several times faster without config caching and in Worker Mode. As a result, a bare `putenv()` call (adding, changing, or removing a variable) made after the first config class was built may not be seen until `BaseConfig::reset()` runs, including in later requests in Worker Mode. See [Config Environment Lookups](../installation/upgrade_480.md#config-environment-lookups).
+- **Database:** `BaseBuilder::getCompiledSelect()`, `getCompiledInsert()`, `getCompiledUpdate()`, `getCompiledDelete()`, `delete()` and `countAllResults()` now clear bindings and binding counters after compilation or execution when their reset parameter is enabled (the default). This prevents accumulation when a builder is reused, but `getBinds()` now returns an empty array after these calls. This also aligns `getCompiledSelect()` with its documented reset behavior, which should match `get()`. Pass `false` for the reset parameter to retain the query and its bindings. Test mode retains bindings needed by its returned raw SQL. See [Query Builder Bindings](../installation/upgrade_480.md#query-builder-bindings).
+- **Database:** The Postgre driver's `$db->error()['code']` previously always returned `''`. It now returns the 5-character SQLSTATE string for query and transaction failures (e.g., `'42P01'`), or `'08006'` for connection-level failures. Code that relied on `$db->error()['code'] === ''` will need updating.
+- **Debug:** `CodeIgniter\Debug\Toolbar\Collectors\BaseCollector` is now `abstract`. Custom toolbar collectors must extend it; code that instantiated it directly with `new BaseCollector()` will now throw an `Error`.
+- **Filters:** HTTP method matching for method-based filters is now case-sensitive. The keys in `Config\Filters::$methods` must exactly match the request method (e.g., `GET`, `POST`). Lowercase method names (e.g., `post`) will no longer match.
+- **HTTP:** Routes defined with `$routes->add()` now also match HTTP `QUERY` requests. If the route has CSRF protection, remember that CSRF verification does not protect safe methods such as `GET` and `QUERY`.
+- **HTTP:** `CodeIgniter\HTTP\Exceptions\RedirectException` no longer redeclares the inherited `$code` property. The status applied to a `Response` passed to the constructor whose status is outside the 301-308 range now comes from the new `protected int $defaultStatusCode = 302` property. Subclasses that overrode `$code` to change that status must override `$defaultStatusCode` instead.
+- **Router:** `RouteCollection` no longer applies `esc(strip_tags())` to namespace, controller, or method values, including controller options passed to `resource()` and `presenter()`. Applications relying on this implicit transformation must preprocess these values before passing them to the router.
+- **Testing:** Tests using the `FeatureTestTrait` must now use uppercase HTTP method names when they call the `call()` method directly (e.g., `$this->call('GET', '/path')` instead of `$this->call('get', '/path')`). Additionally, method-based routes set with `withRoutes()` must also use uppercase method names (e.g., `$this->withRoutes([['GET', 'home', 'Home::index']])`).
+
+### Interface Changes
+
+**NOTE:** If you've implemented your own classes that implement these interfaces from scratch, you will need to update your implementations to include the new methods or method changes to ensure compatibility.
+
+- **Cache:** `CodeIgniter\Cache\CacheInterface::remember()` now accepts a TTL callable. Custom implementations of `CacheInterface` must update the `$ttl` parameter type from `int` to `callable|int`.
+- **Database:** `CodeIgniter\Database\ConnectionInterface` now requires the `afterCommit()`, `afterRollback()`, `inTransaction()`, and `transaction()` methods.
+- **HTTP:** `CodeIgniter\HTTP\ResponseInterface` now requires the `stream()` and `eventStream()` methods, which create streaming and SSE responses. See [Streaming Responses](../outgoing/response.md#streaming-responses).
+- **HTTP:** `CodeIgniter\HTTP\Files\UploadedFileInterface::move()` now returns `static` instead of `bool`. The previous `bool` return was incompatible with `CodeIgniter\Files\File::move()`, which `UploadedFile` extends, so no implementation could satisfy both. See [Upgrading from 4.7.x to 4.8.0](../installation/upgrade_480.md) for the migration.
+- **Logging:** `CodeIgniter\Log\Handlers\HandlerInterface::handle()` now requires a third parameter `array $context = []`. Any custom log handler that overrides `handle()` - whether implementing `HandlerInterface` directly or extending a built-in handler class - must add the parameter to its `handle()` method signature.
+- **Security:** The `SecurityInterface`'s `verify()` method now has a native return type of `static`.
+- **Validation:** `CodeIgniter\Validation\ValidationInterface` now requires the `getValidatedInput()` method, which returns a `CodeIgniter\Input\ValidatedInput` instance.
+
+### Method Signature Changes
+
+- **Database:** `BaseBuilder::join()` and `BaseBuilder::compileJoinTable()` now accept `RawSql|string` for the table argument. Custom builders overriding either method must accept both types. See [Upgrading from 4.7.x to 4.8.0](../installation/upgrade_480.md).
+
+- **CLI:** The `Console::run()` method now accepts an optional `array $tokens` parameter. This allows you to pass an array of command tokens directly to the console runner, which is useful for testing or programmatically running commands. If not provided, it will default to using the global `$argv`.
+
+- **CodeIgniter:** The deprecated parameters in methods have been removed:
+ - `CodeIgniter\CodeIgniter::handleRequest()` no longer accepts the deprecated `$cacheConfig` and `$returnResponse` parameters.
+ - `$cacheConfig` is no longer used and is now hard deprecated. A deprecation notice will be triggered if this is passed to the method.
+ - `$returnResponse` has been removed, since it was already deprecated and unused.
+ - The updated method signature is now `handleRequest(?RouteCollectionInterface $routes, ?Cache $cacheConfig = null)`.
+
+ - `CodeIgniter\CodeIgniter::gatherOutput()` no longer accepts the deprecated `$cacheConfig` parameter. As this is the first parameter, custom uses of this method will need to be updated to remove the parameter.
+
+- **Config:** `CodeIgniter\Config\Services::request()` no longer accepts any parameter.
+
+- **Database:** The following methods have had their signatures updated to remove deprecated parameters:
+ - `CodeIgniter\Database\Forge::_createTable()` no longer accepts the deprecated `$ifNotExists` parameter. The method signature is now `_createTable(string $table, array $attributes)`.
+
+- **HTTP:** `CodeIgniter\HTTP\OutgoingRequest::__construct()` now requires the `$uri` parameter, which was previously `?URI $uri = null`. Passing `null` only worked when a `Host` header was supplied in the same call, since the constructor otherwise dereferences the URI to set that header. Consequently `OutgoingRequest::getUri()` now returns `URI` instead of `URI|null`, matching `OutgoingRequestInterface`. See [Upgrading from 4.7.x to 4.8.0](../installation/upgrade_480.md) for the migration.
+
+- **Model:** `CodeIgniter\BaseModel` now requires the `chunkRows()`, `chunkById()`, and `chunkRowsById()` methods. Custom classes extending `BaseModel` directly must implement them.
+
+- **Session:** The `$max_lifetime` parameter of the following `gc()` methods now has the native `int` type, matching `SessionHandlerInterface`: `ArrayHandler::gc()`, `DatabaseHandler::gc()`, `FileHandler::gc()`, `MemcachedHandler::gc()`, `PostgreHandler::gc()`, `RedisHandler::gc()`.
+
+### Property Scope Changes
+
+- **HTTP:** The following properties have changed their scope (visibility):
+ - `CodeIgniter\HTTP\RequestTrait::$ipAddress` is now private (previously protected).
+
+### Method Scope Changes
+
+- **CLI:** `CodeIgniter\CLI\Commands::getCommandAlternatives()` is now public (previously protected).
+
+- **HTTP:** The following methods have changed their scope (visibility):
+ - `CodeIgniter\HTTP\URI::setUri()` is now private (previously public).
+ - `CodeIgniter\HTTP\URI::refreshPath()` is now protected (previously public).
+ - `CodeIgniter\HTTP\SiteURI::refreshPath()` is now protected (previously public).
+
+### Removed Deprecated Items
+
+- **Autoloader:** Removed the following deprecated methods:
+ - `CodeIgniter\Autoloader\Autoloader::sanitizeFileName()`
+ - `CodeIgniter\Autoloader\Autoloader::discoverComposerNamespaces()`
+
+- **Bootstrap:** The deprecated **system/bootstrap.php** file has been removed.
+
+- **Cache:** Removed the following deprecated methods and constant:
+ - `CodeIgniter\Cache\Handlers\BaseHandler::RESERVED_CHARACTERS` (deprecated since v4.1.5)
+ - `CodeIgniter\Cache\Handlers\FileHandler::writeFile()` (deprecated since v4.6.0)
+ - `CodeIgniter\Cache\Handlers\FileHandler::deleteFile()` (deprecated since v4.6.0)
+ - `CodeIgniter\Cache\Handlers\FileHandler::getDirFileInfo()` (deprecated since v4.6.0)
+ - `CodeIgniter\Cache\Handlers\FileHandler::getFileInfo()` (deprecated since v4.6.0)
+
+- **CLI:** Removed the following deprecated properties and methods:
+ - `CodeIgniter\CLI\BaseCommand::getPad()`
+ - `CodeIgniter\CLI\CLI::$readline_support`
+ - `CodeIgniter\CLI\CLI::$wait_msg`
+ - `CodeIgniter\CLI\CLI::isWindows()`
+ - `CodeIgniter\CLI\GeneratorTrait::execute()`
+
+- **CodeIgniter:** Removed the following deprecated properties and methods:
+ - `CodeIgniter\CodeIgniter::$cacheTTL`
+ - `CodeIgniter\CodeIgniter::$returnResponse`
+ - `CodeIgniter\CodeIgniter::initializeKint()`
+ - `CodeIgniter\CodeIgniter::detectEnvironment()`
+ - `CodeIgniter\CodeIgniter::bootstrapEnvironment()`
+ - `CodeIgniter\CodeIgniter::forceSecureAccess()`
+ - `CodeIgniter\CodeIgniter::displayCache()`
+ - `CodeIgniter\CodeIgniter::cache()`
+ - `CodeIgniter\CodeIgniter::cachePage()`
+ - `CodeIgniter\CodeIgniter::generateCacheName()`
+ - `CodeIgniter\CodeIgniter::displayPerformanceMetrics()`
+ - `CodeIgniter\CodeIgniter::determinePath()`
+ - `CodeIgniter\CodeIgniter::callExit()`
+ - `CodeIgniter\Test\MockCodeIgniter::callExit()`
+
+- **Config:** Removed the following deprecated property:
+ - `CodeIgniter\Config\BaseService::$services` (deprecated since v4.5.0)
+
+- **Database:** Removed the following deprecated properties and methods:
+ - `CodeIgniter\Database\BaseBuilder::setInsertBatch()`
+ - `CodeIgniter\Database\BaseBuilder::setUpdateBatch()`
+ - `CodeIgniter\Database\BaseBuilder::cleanClone()`
+ - `CodeIgniter\Database\BaseConnection::$strictOn`
+ - `CodeIgniter\Database\Forge::$createTableIfStr`
+ - `CodeIgniter\Database\Seeder::$faker`
+ - `CodeIgniter\Database\Seeder::faker()`
+ - `CodeIgniter\Database\OCI8\Forge::$createTableIfStr`
+ - `CodeIgniter\Database\OCI8\Forge::getError()`
+ - `CodeIgniter\Database\SQLSRV\Forge::$createTableIfStr`
+
+- **Debug:** Removed the following deprecated properties and methods:
+ - `CodeIgniter\Debug\Toolbar\Collectors\BaseCollector::cleanPath()` (deprecated since v4.2.0)
+ - `CodeIgniter\Debug\Exceptions::$ob_level` (deprecated since v4.4.0)
+ - `CodeIgniter\Debug\Exceptions::$viewPath` (deprecated since v4.4.0)
+ - `CodeIgniter\Debug\Exceptions::determineView()` (deprecated since v4.4.0)
+ - `CodeIgniter\Debug\Exceptions::render()` (deprecated since v4.4.0)
+ - `CodeIgniter\Debug\Exceptions::collectVars()` (deprecated since v4.4.0)
+ - `CodeIgniter\Debug\Exceptions::maskSensitiveData()` (deprecated since v4.4.0)
+ - `CodeIgniter\Debug\Exceptions::maskData()` (deprecated since v4.4.0)
+ - `CodeIgniter\Debug\Exceptions::cleanPath()` (deprecated since v4.2.0)
+ - `CodeIgniter\Debug\Exceptions::describeMemory()` (deprecated since v4.4.0)
+ - `CodeIgniter\Debug\Exceptions::highlightFile()` (deprecated since v4.4.0)
+
+- **Exceptions:** Removed the following static constructors of `FrameworkException` and its child classes:
+ - `CodeIgniter\Exceptions\DownloadException::forCannotSetCache()`
+ - `CodeIgniter\Exceptions\FrameworkException::forMissingExtension()`
+ - `CodeIgniter\Honeypot\Exceptions\HoneypotException::forNoHiddenValue()`
+ - `CodeIgniter\HTTP\Exceptions\HTTPException::forInvalidSameSiteSetting()`
+ - `CodeIgniter\Security\Exceptions\SecurityException::forInvalidSameSite()`
+ - `CodeIgniter\Session\Exceptions\SessionException::forInvalidSameSiteSetting()`
+
+- **Filters:** Removed the following deprecated properties and methods:
+ - `CodeIgniter\Filters\Filters::$arguments` (deprecated since v4.6.0)
+ - `CodeIgniter\Filters\Filters::$argumentsClass` (deprecated since v4.6.0)
+ - `CodeIgniter\Filters\Filters::getArguments()` (deprecated since v4.6.0)
+
+- **HTTP:** Removed the following deprecated properties and methods:
+ - `CodeIgniter\HTTP\Message::getHeaders()` (deprecated since v4.0.5)
+ - `CodeIgniter\HTTP\Message::getHeader()` (deprecated since v4.0.5)
+ - `CodeIgniter\HTTP\RequestTrait::getEnv()` (deprecated since v4.4.4)
+ - `CodeIgniter\HTTP\URI::$uriString` (deprecated since v4.4.0)
+ - `CodeIgniter\HTTP\URI::$baseURL` (deprecated since v4.4.0)
+ - `CodeIgniter\HTTP\URI::setBaseURL()` (deprecated since v4.4.0)
+ - `CodeIgniter\HTTP\URI::getBaseURL()` (deprecated since v4.4.0)
+ - `CodeIgniter\HTTP\URI::setScheme()` (deprecated since v4.4.0)
+ - `CodeIgniter\HTTP\SiteURI::$segments` (deprecated since v4.4.0)
+ - `CodeIgniter\HTTP\SiteURI::setBaseURL()` (deprecated since v4.4.0)
+ - `CodeIgniter\HTTP\SiteURI::setURI()` (deprecated since v4.4.0)
+
+- **Security:** Removed the following deprecated properties and methods:
+ - `CodeIgniter\Security\SecurityInterface::sanitizeFilename()` (deprecated since v4.6.2)
+ - `CodeIgniter\Security\Security::sanitizeFilename()` (deprecated since v4.6.2)
+ - `CodeIgniter\Security\Security::$csrfProtection` (deprecated since v4.4.0)
+ - `CodeIgniter\Security\Security::$tokenRandomize` (deprecated since v4.4.0)
+ - `CodeIgniter\Security\Security::$tokenName` (deprecated since v4.4.0)
+ - `CodeIgniter\Security\Security::$headerName` (deprecated since v4.4.0)
+ - `CodeIgniter\Security\Security::$expires` (deprecated since v4.4.0)
+ - `CodeIgniter\Security\Security::$regenerate` (deprecated since v4.4.0)
+ - `CodeIgniter\Security\Security::$redirect` (deprecated since v4.4.0)
+ - `CodeIgniter\Security\Security::$sameSite` (deprecated since v4.4.0)
+
+## Enhancements
+
+### API
+
+- Added per-type sparse fieldsets to API transformers. A transformer may declare a `$type` so clients can request `?fields[]=...` to filter the fields of that resource type independently, including for nested/related resources, while the flat `?fields=...` continues to apply to the root resource only. See [API Resources](../outgoing/api_transformers.md#api-resources).
+
+### Commands
+
+- Added a new attribute-based command style built on `AbstractCommand` and the `#[Command]` attribute, with `configure()` / `isAvailable()` / `initialize()` / `interact()` / `execute()` hooks and typed `Argument` / `Option` definitions. The legacy `BaseCommand` style continues to work. See [Modern Spark Commands](../cli/cli_modern_commands.md).
+- Modern commands can now declare command aliases through an `aliases` list on the `#[Command]` attribute. Aliases resolve to the command at dispatch (`php spark ` and `help `), are listed as their own rows in `spark list`, and appear in an `Aliases:` section of `help `. An alias that collides with an existing command name or another alias is rejected at discovery. See [Modern Spark Commands](../cli/cli_modern_commands.md).
+- Modern commands can now be hidden with `hidden: true` on the `#[Command]` attribute. A hidden command and its aliases are left out of `spark list` and of the suggestions for a mistyped command name, but still run by exact name or alias and still have a `help` page. `AbstractCommand::isHidden()` and `Commands::isHiddenCommand()` report the flag. See [Hidden Commands](../cli/cli_modern_commands.md#hidden-commands).
+- Modern commands can now opt out of the spark header with `headerless: true` on the `#[Command]` attribute, with the same effect as always passing `--no-header`. `help ` and ` --help` still print the header. `AbstractCommand::isHeaderless()` and `Commands::isHeaderlessCommand()` report the flag. See [Commands Without the Header](../cli/cli_modern_commands.md#commands-without-the-header).
+- Every modern command now ships with a `--no-interaction` / `-N` flag that skips the `interact()` hook, plus public `isInteractive()` / `setInteractive()` methods on `AbstractCommand`. `isInteractive()` also auto-detects piped or CI environments by probing STDIN for a TTY, and the state cascades to sub-commands invoked via `$this->call(...)`. See [Non-Interactive Mode](../cli/cli_modern_commands.md#non-interactive-mode).
+- `spark help ` now shows the default value of each option that accepts a value (e.g., `[default: "file"]`), the same way argument defaults are already displayed. Boolean flags and negatable toggles, which have no meaningful default to show, are unaffected.
+- You can now retrieve the last executed command in the console using the new `Console::getCommand()` method. This is useful for logging, debugging, or any situation where you need to know which command was run.
+- `CLI` now supports the `--` separator to mean that what follows are arguments, not options. This allows you to have arguments that start with `-` without them being treated as options. For example: `spark my:command -- --myarg` will pass `--myarg` as an argument instead of an option.
+- `CLI` now supports options with values specified using an equals sign (e.g., `--option=value`) in addition to the existing space-separated syntax (e.g., `--option value`). This provides more flexibility in how you can pass options to commands.
+- `CLI` now supports parsing array options written multiple times (e.g., `--option=value1 --option=value2`) into an array of values. This allows you to easily pass multiple values for the same option without needing to use a comma-separated string. When used with `CLI::getOption()`, an array option will return its last value (for example, in this case, `value2`). To retrieve all values for an array option, use `CLI::getRawOption()`.
+- Likewise, the `command()` function now also supports the above enhancements for command-line option parsing when using the function to run commands from code.
+- Added `make:request` generator command to scaffold [Form Request](../incoming/form_requests.md#form-requests) classes.
+- Added `key:rotate` command to demote the current `encryption.key` to `encryption.previousKeys` in **.env** and generate a new key. See [Rotating with the `key:rotate` Command](../libraries/encryption.md#rotating-with-the-keyrotate-command).
+- Added `AbstractCommand::callSilently()` to invoke another command with its output discarded, restoring the prior output afterwards. See [Calling Silently](../cli/cli_modern_commands.md#calling-silently).
+- The output side of the `CLI` class now lives in instance classes, with `CLI` delegating to them: `Output` (`print()`, `write()`, `error()`, `newLine()`, `deferNewLine()`, `beep()`, `clearScreen()`, `color()`, `wrap()`), `Terminal` (dimensions and color support), `ProgressBar`, `Table`, and `Color` (ANSI codes and `Color::width()`). `CLI::getOutput()` and `CLI::getTerminal()` return the shared instances. See [The Classes Behind the Facade](../cli/cli_library.md#the-classes-behind-the-facade).
+- Modern commands can write through `$this->getOutput()`, the same `Output` used by the static `CLI` methods. `AbstractCommand::run()` and `Commands::runCommand()` accept an `Output` to run against, which `Console` passes down and sub-commands inherit. See [Writing Output](../cli/cli_modern_commands.md#writing-output).
+- The prompt methods of `CLI` now delegate to `Prompt` (`ask()`, `askByKey()`, `askByMultipleKeys()`), which reads through an `Output` and accepts the validation that checks the answers. `CLI::getPrompt()` and `AbstractCommand::getPrompt()` return one on the shared output and on the output of the run. See [The Classes Behind the Facade](../cli/cli_library.md#the-classes-behind-the-facade).
+- Added `Prompt::select()` and `Prompt::multiselect()`, which pick options with the arrow keys and the space bar, and ask for numbers where keys cannot be read. See [select() and multiselect()](../cli/cli_library.md#select-and-multiselect).
+- Added `--silent`, `--quiet` / `-q`, and `--verbose` / `-v`, `-vv`, `-vvv` to every command, with `SHELL_VERBOSITY` as the fallback. Modern commands can write at a `Verbosity` through `$this->getOutput()`. `lang:find` uses the global `--verbose` in place of its own. See [Verbosity](../cli/spark_commands.md#verbosity) and [Writing at a Verbosity](../cli/cli_modern_commands.md#writing-at-a-verbosity).
+- Color detection now honors `FORCE_COLOR` (see ), which turns color on even when the output is piped, and `COLORTERM`. `NO_COLOR` still wins over both. The color variables can also be set in the **.env** file.
+- Added `AbstractGeneratorCommand` and the `#[GeneratorCommand]` attribute, the modern counterpart of `GeneratorTrait` for commands that generate files from templates. Generator commands built on it accept the `-n`, `-s`, and `-f` shortcuts for `--namespace`, `--suffix`, and `--force`. See [Modern Generator Commands](../cli/cli_modern_generators.md).
+- Legacy `BaseCommand::call()` can now invoke modern commands: integer-keyed params are passed as arguments and string-keyed params as options, which the target command validates like any other input.
+- The `make:controller` command now accepts `-b` and `-r` as shortcuts for `--bare` and `--restful`, and rejects an invalid `--restful` value on non-interactive runs instead of prompting.
+- The `make:model` command now accepts `-t`, `-g`, and `-r` as shortcuts for `--table`, `--dbgroup`, and `--return`, and rejects an invalid `--return` value on non-interactive runs instead of prompting.
+- The `make:command` command now accepts `-c`, `-t`, and `-g` as shortcuts for `--command`, `--type`, and `--group`, and rejects an invalid `--type` value on non-interactive runs instead of prompting.
+- The `make:migration` command now accepts `-t` and `-g` as shortcuts for `--table` and `--dbgroup`.
+- The `make:scaffold` command now accepts `-b`, `-t`, and `-g` as shortcuts for `--bare`, `--table`, and `--dbgroup`, and asks for a missing class name once instead of once per generated file.
+- **spark** now offers to run the closest matching command when a command name is mistyped on an interactive run. See [Correcting a Mistyped Command](../cli/spark_commands.md#correcting-a-mistyped-command).
+- Modern commands can now opt in to prompting for missing required arguments on interactive runs by implementing the new `PromptsForMissingInputInterface` marker interface. Prompt labels can be customized via `getArgumentPromptLabels()`, and an `afterPrompting()` hook runs when prompting occurred. Non-interactive runs keep failing fast with the missing-arguments error. See [Prompting for Missing Arguments](../cli/cli_modern_commands.md#prompting-for-missing-arguments).
+- The `migrate`, `migrate:rollback`, `migrate:refresh`, and `migrate:status` commands now accept long option names (`--namespace`, `--group`, `--batch`, `--force`) alongside their existing short forms (`-n`, `-g`, `-b`, `-f`).
+- The `namespaces` command now accepts long option names (`--config-only`, `--raw`, `--max-length`) alongside its existing short forms (`-c`, `-r`, `-m`).
+- The `worker:install` and `worker:uninstall` commands now accept the `-f` short option alongside `--force`.
+- Added `NullInputOutput`, an `InputOutput` sink that discards all writes and returns an empty string from `input()`.
+- The `spark routes` command now resolves the Before/After Filters columns for routes that use custom placeholders registered via `$routes->addPlaceholder()`. Sample URIs for custom placeholders are derived from the placeholder regex when possible, and can be overridden through the new `$placeholderSamples` property in `Config\Routing`. See [Sample Values for `spark routes`](../incoming/routing.md#sample-values-for-spark-routes).
+
+### Testing
+
+- Added `assertSameSql()` to `CIUnitTestCase` to compare generated SQL while ignoring newlines in the actual SQL.
+- Added `FeatureTestTrait::withFiles()` and `MockUploadedFile` for testing routes that validate and move uploaded files.
+
+### Database
+
+- Added `afterCommit()` and `afterRollback()` transaction callbacks to database connections. These callbacks run after the outermost transaction commits or rolls back. See [Running Code after Commit or Rollback](../database/transactions.md#running-code-after-commit-or-rollback).
+- Added `ConstraintViolationException` with `UniqueConstraintViolationException`, `ForeignKeyConstraintViolationException`, `NotNullConstraintViolationException`, and `CheckConstraintViolationException` subclasses for typed database integrity errors. See [Constraint Violation Exceptions](../database/queries.md#constraint-violation-exceptions).
+- Added `inTransaction()` to database connections to check whether the connection is inside an active CodeIgniter-managed transaction. See [Checking Transaction State](../database/transactions.md#checking-transaction-state).
+- Added support for PHP `BackedEnum` values in database escaping, query bindings, and Query Builder bound values.
+- Prepared query execution failures now throw or store typed database exceptions such as `ConstraintViolationException` and `RetryableTransactionException` when applicable, matching normal query failures.
+- Added `RetryableTransactionException` for driver-specific retryable transaction failures such as deadlocks and serialization failures. See [Classifying Retryable Transaction Failures](../database/transactions.md#classifying-retryable-transaction-failures).
+- Added the `transaction()` method to database connections to run a callback inside a transaction, with optional retry attempts for retryable transaction failures and scoped `transException` and `resetTransStatus` options. See [Running Transactions with a Closure](../database/transactions.md#running-transactions-with-a-closure).
+- Added `trustServerCertificate` option to `SQLSRV` database connections in `Config\Database`. Set it to `true` to trust the server certificate without CA validation when using encrypted connections.
+
+#### Query Builder
+
+- `BaseBuilder::join()` now accepts `RawSql` as the table argument, allowing raw JOIN targets such as subqueries. See [RawSql](../database/query_builder.md#rawsql_1).
+- Added `exists()` and `doesntExist()` to Query Builder to check whether the current Query Builder query would return at least one row. See [$builder->exists()](../database/query_builder.md#builder-exists).
+- Added `explain()` to Query Builder to run execution-plan queries for the current `SELECT` query. See [$builder->explain()](../database/query_builder.md#builder-explain).
+- Added `havingBetween()`, `orHavingBetween()`, `havingNotBetween()`, and `orHavingNotBetween()` to Query Builder. See [$builder->havingBetween()](../database/query_builder.md#builder-havingbetween).
+- Added `likeAny()` and `orLikeAny()` to Query Builder to search one value across multiple fields with grouped `OR` `LIKE` conditions. See [$builder->likeAny()](../database/query_builder.md#builder-likeany).
+- Added `nowait()` and `skipLocked()` lock wait modifiers for pessimistic `SELECT` locks on supported drivers. See [No Wait](../database/query_builder.md#no-wait) and [Skip Locked](../database/query_builder.md#skip-locked).
+- Added `sharedLock()` to add pessimistic read locks to `SELECT` queries on supported drivers. See [Shared Lock](../database/query_builder.md#shared-lock).
+- Added `whereBetween()`, `orWhereBetween()`, `whereNotBetween()`, and `orWhereNotBetween()` to Query Builder. See [$builder->whereBetween()](../database/query_builder.md#builder-wherebetween).
+- Added `whereColumn()` and `orWhereColumn()` to compare one column to another column while protecting identifiers by default. See [$builder->whereColumn()](../database/query_builder.md#builder-wherecolumn).
+- Added `whereExists()`, `orWhereExists()`, `whereNotExists()`, and `orWhereNotExists()` to add `EXISTS` and `NOT EXISTS` subquery conditions. See [$builder->whereExists()](../database/query_builder.md#builder-whereexists).
+- Added new `incrementMany()` and `decrementMany()` methods to `CodeIgniter\Database\BaseBuilder` for performing bulk increment/decrement operations.
+- Added `lockForUpdate()` to add pessimistic write locks to `SELECT` queries on supported drivers. See [Lock for Update](../database/query_builder.md#lock-for-update).
+
+#### Forge
+
+#### Others
+
+- Added new `timezone` option to connection array in `Config\Database` config. This ensures consistent timestamps between model operations and database functions like `NOW()`. Supported drivers: **MySQLi**, **Postgre**, and **OCI8**. See [timezone](../database/configuration.md#timezone) for details.
+- Added `$db->getLastException()` which returns the typed exception even when `DBDebug` is `false`. See [$db->getLastException()](../database/queries.md#db-getlastexception).
+- Added `DatabaseException::getDatabaseCode()` returning the native driver error code as `int|string`; `getCode()` is constrained to `int` by PHP's `Throwable` interface and cannot carry string SQLSTATE codes.
+
+### Debug
+
+- Added a **Copy Details** button to detailed HTML exception pages.
+
+### Model
+
+- Added `chunkRows()`, `chunkById()`, and `chunkRowsById()` methods to `CodeIgniter\Model` for processing large datasets in chunks.
+- Added `encrypted`, `encrypted-json` and `encrypted-json-array` casting for Entity properties and Model fields using the Encryption service. See [encrypted](../models/model.md#encrypted).
+- Added `firstOrInsert()` method to `CodeIgniter\Model` that finds the first row matching the given attributes or inserts a new one. See [firstOrInsert()](../models/model.md#firstorinsert).
+- Added `$insertOnlyFields` and `setInsertOnlyFields()` to `CodeIgniter\Model` to remove configured fields from Model update operations while allowing them during inserts. See [$insertOnlyFields](../models/model.md#insertonlyfields).
+- Added `$throwOnDisallowedFields` and `throwOnDisallowedFields()` to `CodeIgniter\Model` to throw a `DataException` when write data contains fields that would otherwise be discarded by `$allowedFields`. See [$throwOnDisallowedFields](../models/model.md#throwondisallowedfields).
+- Added `withLockedRow()` to `CodeIgniter\Model` to run a callback with one row reloaded inside a transaction using `lockForUpdate()`. See [withLockedRow()](../models/model.md#withlockedrow).
+
+### Libraries
+
+- **Cache:** Added support for TTL callables in the `remember()` method of cache handlers. This allows you to specify a callable that returns a TTL value, which can be useful for dynamic TTL.
+- **Context:** This new feature allows you to easily set and retrieve normal or hidden contextual data for the current request. See [Context](../general/context.md#context) for details.
+- **Images:** Added support for the AVIF file format.
+- **Locks:** Added [Atomic Locks](../libraries/locks.md) for owner-aware, cross-process mutual exclusion backed by supported cache handlers: **File**, **Redis**, **Predis**, and **Memcached**. Memcached support has driver-specific release limitations because Memcached has no atomic compare-and-delete command.
+- **Logging:** Log handlers now receive the full context array as a third argument to `handle()`. When `$logGlobalContext` is enabled, the CI global context is available under the `HandlerInterface::GLOBAL_CONTEXT_KEY` key. Built-in handlers append it to the log output; custom handlers can use it for structured logging.
+- **Logging:** Added [per-call context logging](../general/logging.md#per-call-context-logging) with three new `Config\Logger` options (`$logContext`, `$logContextTrace`, `$logContextUsedKeys`). Per PSR-3, a `Throwable` in the `exception` context key is automatically normalized to a meaningful array. All options default to `false`.
+- **Logging:** Added the `stopChainOnFailure` handler option for `FileHandler` and `ErrorlogHandler`. Set it to `false` to let the remaining handlers run when the handler fails to write the log. It defaults to `true`, which keeps the existing behavior. See [Stopping the Handler Chain on Failure](../general/logging.md#stopping-the-handler-chain-on-failure).
+- **Security:** Added [Fetch Metadata based CSRF protection](../libraries/security.md#fetch-metadata-csrf-protection) with token fallback.
+
+### Helpers and Functions
+
+- [Array Helper](../helpers/array_helper.md) gained five new dot-path functions: `dot_array_has()`, `dot_array_set()`, `dot_array_unset()`, `dot_array_only()`, and `dot_array_except()`.
+- [Array Helper](../helpers/array_helper.md) dot-path read operations now support object rows (including `Entity`) in `dot_array_search()`, `dot_array_has()`, `dot_array_only()`, `dot_array_except()`, and `array_group_by()`. `dot_array_only()` and `dot_array_except()` still return arrays. If the source itself is an object, it is read as an array-like value. Object values inside the source are kept unchanged when selected as a whole or left untouched. Partial object paths are returned as arrays.
+
+### HTTP
+
+- Added support for the HTTP `QUERY` method in routing, feature tests, `CURLRequest`, and `FormRequest` input source selection.
+- Added the `retry` option to `CURLRequest` for retrying failed responses with configurable delays, retryable status codes, optional transient cURL error retries, and `Retry-After` support. See [retry](../libraries/curlrequest.md#retry).
+- Added [Form Requests](../incoming/form_requests.md#form-requests) - a new `FormRequest` base class that encapsulates validation rules, custom error messages, and authorization logic for a single HTTP request.
+- Added `IncomingRequest::input()` to read GET, POST, JSON, and raw request data through `InputData`.
+- Added support for streaming responses. The `StreamResponse` class streams a body that is generated while it is being sent, such as large dataset exports or AI/LLM token streams, and the `SSEResponse` class extends it to stream Server-Sent Events (SSE) over HTTP. Both can be created via the new `$this->response->stream()` and `$this->response->eventStream()` factory methods. See [Streaming Responses](../outgoing/response.md#streaming-responses) and [Server-Sent Events (SSE)](../outgoing/response.md#server-sent-events-sse).
+- `Response` and its child classes no longer require `Config\App` to be passed to their constructors. Consequently, `CURLRequest`'s `$config` parameter is unused and will be removed in a future release.
+- `Response` now lazily loads the `ContentSecurityPolicy`, `Cookie`, and `CookieStore` classes only when used, instead of instantiating them on every request in the constructor. Requests that do not touch CSP or cookies no longer incur the cost of loading these classes.
+- `CLIRequest` now supports the `--` separator to mean that what follows are arguments, not options. This allows you to have arguments that start with `-` without them being treated as options. For example: `php index.php command -- --myarg` will pass `--myarg` as an argument instead of an option.
+- `CLIRequest` now supports options with values specified using an equals sign (e.g., `--option=value`) in addition to the existing space-separated syntax (e.g., `--option value`). This provides more flexibility in how you can pass options to CLI requests.
+- Added `enable()` and `disable()` methods to `ContentSecurityPolicy` to enable or disable the CSP header for the current request. See [Enable or Disable CSP at Runtime](../outgoing/csp.md#enable-or-disable-csp-at-runtime).
+- Added `$enableStyleNonce` and `$enableScriptNonce` options to `Config\App` to control whether nonces are automatically added to the `style-*` and `script-*` directives in the Content Security Policy (CSP) header when CSP is enabled. See [Control Nonce Generation](../outgoing/csp.md#control-nonce-generation) for details.
+- Added `URI::withQuery()`, `URI::withQueryArray()`, `URI::withoutQueryVars()`, and `URI::withOnlyQueryVars()` to return cloned URIs with replaced or filtered query variables.
+- Added `URI::withQueryVar()` and `URI::withQueryVars()` to return a cloned URI with query variables added or replaced.
+- `URI` now accepts an optional boolean second parameter in the constructor, defaulting to `false`, to control how the query string is parsed on instantiation. This is the behavior of `->useRawQueryString()` brought into the constructor for convenience. Previously, you had to call `$uri->useRawQueryString(true)->setURI($uri)` to get this behavior. Now you can simply do `new URI($uri, true)`.
+- `CLIRequest` now supports parsing array options written multiple times (e.g., `--option=value1 --option=value2`) into an array of values. This allows you to easily pass multiple values for the same option without needing to use a comma-separated string. When used with `CLIRequest::getOption()`, an array option will return its last value (for example, in this case, `value2`). To retrieve all values for an array option, use `CLIRequest::getRawOption()`.
+
+### Validation
+
+- Custom rule methods that set an error via the `&$error` reference parameter now support the `{field}`, `{param}`, and `{value}` placeholders, consistent with language-file and `setRule()`/`setRules()` error messages.
+- Added `Validation::getValidatedInput()` to access validated data through a `ValidatedInput` object.
+
+### Others
+
+- **Config:** Added optional `CodeIgniter\Config\Merge` directives for [Registrars](../general/configuration.md#registrars) to control replacing, deep merging, and ordered list additions. Existing registrar merge behavior is unchanged. See [Controlling how values are merged](../general/configuration.md#controlling-how-values-are-merged).
+- **Float and Double Casting:** Added support for precision and rounding mode when casting to float or double in entities.
+- Added `CodeIgniter\Input\InputData`, `ValidatedInput`, and `InputDataFactory` for reusable typed input data objects.
+- Float and Double casting now throws `CastException::forInvalidFloatRoundingMode()` if a rounding mode other than up, down, even, or odd is provided.
+- **Filters:** Added `RequestId` filter for request tracing and correlation logging. The filter stores the request ID in the request context and automatically adds the `X-Request-ID` response header. Incoming `X-Request-ID` headers are used when valid. See [Request ID](../incoming/filters.md#request-id) for details.
+- **Environment:** Added `CodeIgniter\EnvironmentDetector` class and corresponding `environment` service as a mockable wrapper around the `ENVIRONMENT` constant. Framework internals that previously compared `ENVIRONMENT` directly now go through this service, making environment-specific branches reachable in tests via `Services::injectMock()`. See [The `environment` service](../general/environments.md#the-environment-service).
+- **Time:** Added `Time::between()`, `Time::min()`, and `Time::max()` comparison helpers. See [between](../libraries/time.md#between), [min](../libraries/time.md#min), and [max](../libraries/time.md#max).
+
+## Message Changes
+
+- Added new language keys:
+ - `Cache.unsupportedLockStore` (`CacheException::forUnsupportedLockStore()`)
+ - `CLI.commandAlias` and `CLI.helpAliases` (command alias rendering in `list` and `help`)
+ - `CLI.altCommandNone`, `CLI.altCommandRun`, and `CLI.altCommandSelect` (correcting a mistyped command)
+ - `CLI.generator.invalidCommandType` (`make:command` rejecting an invalid `--type`)
+ - `CLI.generator.invalidReturnType` (`make:model` rejecting an invalid `--return`)
+ - `Commands.invalidCommandAlias`, `Commands.commandAliasSameAsName`, `Commands.duplicateCommandAlias`, `Commands.aliasClashesWithCommandName`, and `Commands.aliasClashesWithAlias` (command alias validation)
+
+- Removed deprecated language keys tied to removed exception constructors:
+ - `Core.missingExtension` (`FrameworkException::forMissingExtension()`)
+ - `HTTP.cannotSetCache` (`DownloadException::forCannotSetCache()`)
+ - `HTTP.disallowedAction`
+ - `HTTP.invalidSameSiteSetting` (`HTTPException::forInvalidSameSiteSetting()`)
+ - `Security.invalidSameSite` (`SecurityException::forInvalidSameSite()`)
+ - `Session.invalidSameSiteSetting` (`SessionException::forInvalidSameSiteSetting()`)
+
+## Changes
+
+- **Config:** Added the `md` key for `Config\Mimes::$mimes` for Markdown files.
+- **Router:** Reduced route registration overhead in `RouteCollection::create()` by skipping placeholder replacement for route keys without `:` and calling `helper('array')` only once per collection.
+
+## Deprecations
+
+- **CLI:** The `CLI::parseCommandLine()` method is now deprecated and will be removed in a future release. The `CLI` class now uses the new `CommandLineParser` class to handle command-line argument parsing.
+- **CLI:** Returning a non-integer exit code from a command is now deprecated and will trigger a deprecation notice. Command methods should return an integer exit code (e.g., `0` for success, non-zero for errors) to ensure proper behavior across all platforms.
+- **CLI:** `Commands::run()` is now deprecated in favor of `Commands::runLegacy()` for legacy `BaseCommand` commands, and `Commands::runCommand()` for modern `AbstractCommand` commands.
+- **CLI:** The `$commands` parameter of `Commands::verifyCommand()` and the `$collection` parameter of `Commands::getCommandAlternatives()` are no longer used. Passing a non-empty array for either will trigger a deprecation notice.
+- **CLI:** `BaseCommand` is now deprecated in favor of `AbstractCommand`. Running a command that extends it triggers a deprecation notice. See [Modern Spark Commands](../cli/cli_modern_commands.md).
+- **CLI:** `GeneratorTrait` is now deprecated in favor of `AbstractGeneratorCommand`. See [Modern Generator Commands](../cli/cli_modern_generators.md).
+- **CLI:** The `CLI.generator.viewName.cell` language string is deprecated. It was never displayed, since `make:cell` only prompts for the class name.
+- **CLI:** The `CLI::$foreground_colors`, `CLI::$background_colors`, `CLI::$lastWrite`, `CLI::$height`, `CLI::$width`, `CLI::$isColored`, `CLI::$io`, and `CLI::$stdoutToStderr` properties are deprecated and no longer used. Their state lives in `Color`, `Output`, and `Terminal`. See [CLI Output Classes](../installation/upgrade_480.md#cli-output-classes).
+- **CLI:** `CLI::validate()` is deprecated and no longer used by the prompts. Calling it triggers a deprecation notice. Override `Prompt::validate()` instead. See [CLI Prompt](../installation/upgrade_480.md#cli-prompt).
+- **HTTP:** The `CLIRequest::parseCommand()` method is now deprecated and will be removed in a future release. The `CLIRequest` class now uses the new `CommandLineParser` class to handle command-line argument parsing.
+- **HTTP:** `URI::setSilent()` is now hard deprecated. This method was previously only marked as deprecated. It will now trigger a deprecation notice when used.
+
+## Bugs Fixed
+
+See the repo's [CHANGELOG.md](https://github.com/codeigniter4/CodeIgniter4/blob/develop/CHANGELOG.md) for a complete list of bugs fixed.
diff --git a/docs/cli/cli_commands.md b/docs/cli/cli_commands.md
index 1654b2832934..353828421495 100644
--- a/docs/cli/cli_commands.md
+++ b/docs/cli/cli_commands.md
@@ -2,6 +2,9 @@
While the ability to use Controllers via CLI like any other route is convenient, you might find times where you need a little something different. That's where Spark commands come in. They are simple classes that do not need to have routes defined, making them perfect for building tools that developers can use to make their jobs simpler, whether by handling migrations or database seeding, checking cronjob status, or even building out custom code generators for your company.
+!!! danger "Deprecated since version 4.8.0"
+ `BaseCommand` is deprecated, and running a command that extends it triggers a deprecation notice. Write new commands against `AbstractCommand` instead. See [Modern Spark Commands](cli_modern_commands.md).
+
## Creating New Commands
You can very easily create new commands to use in your own development. Each class must be in its own file, must extend `CodeIgniter\CLI\BaseCommand`, and must implement the `run()` method.
@@ -139,7 +142,7 @@ $this->call('command_two', $params);
- **Parameters**
- **$e** `Throwable` The exception to use for error reporting.
-A convenience method to maintain a consistent and clear error output to the CLI:
+A convenience method to maintain a consistent and clear error output on STDERR:
```php
options as $option => $description) {
* --all Set for all namespaces, will ignore (-n) option
*/
```
-
-#### getPad($array, $pad)
-
-!!! danger "Deprecated since version 4.0.5"
- Use `CodeIgniter\CLI\BaseCommand::setPad()` instead.
-
-- **Parameters**
- - **$array** `array` The `$key` => `$value` array.
- - **$pad** `integer` The pad spaces.
-
-A method to calculate padding for `$key => $value` array output. The padding can be used to output a well-formatted table in CLI.
diff --git a/docs/cli/cli_generators.md b/docs/cli/cli_generators.md
index a5883283baa8..d27277dc142c 100644
--- a/docs/cli/cli_generators.md
+++ b/docs/cli/cli_generators.md
@@ -8,10 +8,14 @@ All built-in generators reside under the `Generators` group when listed using `p
```console
php spark help
+php spark --help
```
where `` will be replaced with the command to check.
+!!! note "Note"
+ The single-letter option shortcuts listed below (`-n`, `-s`, `-f`) are available since v4.8.0.
+
!!! note "Note"
Do you need to have the generated code in a subfolder? If you want to create a controller class to reside in the `Admin` subfolder of the main `Controllers` folder, you will just need to prepend the subfolder to the class name, like this: `php spark make:controller admin/login`. This command will create the `Login` controller in the `Controllers/Admin` subfolder with a namespace of `App\Controllers\Admin`.
@@ -43,8 +47,8 @@ make:cell [options]
#### Options:
-- `--namespace`: Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
-- `--force`: Set this flag to overwrite existing files on destination.
+- `--namespace` (`-n`): Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
+- `--force` (`-f`): Set this flag to overwrite existing files on destination.
### make:command
@@ -62,12 +66,15 @@ make:command [options]
#### Options:
-- `--command`: The command name to run in spark. Defaults to `command:name`.
-- `--group`: The group/namespace of the command. Defaults to `App` for basic commands, and `Generators` for generator commands.
-- `--type`: The type of command, whether a `basic` command or a `generator` command. Defaults to `basic`.
-- `--namespace`: Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
-- `--suffix`: Append the component suffix to the generated class name.
-- `--force`: Set this flag to overwrite existing files on destination.
+- `--command` (`-c`): The command name to run in spark. Defaults to `command:name`.
+- `--type` (`-t`): The type of command, whether a `basic` command or a `generator` command. Defaults to `basic`.
+- `--group` (`-g`): The group of the command. Defaults to `App` for basic commands, and `Generators` for generator commands.
+- `--namespace` (`-n`): Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
+- `--suffix` (`-s`): Append the component suffix to the generated class name.
+- `--force` (`-f`): Set this flag to overwrite existing files on destination.
+
+!!! note "Note"
+ Since v4.8.0, the generated class is a [modern command](cli_modern_commands.md): a `basic` command extends `AbstractCommand`, and a `generator` command extends [AbstractGeneratorCommand](cli_modern_generators.md).
### make:config
@@ -85,9 +92,9 @@ make:config [options]
#### Options:
-- `--namespace`: Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
-- `--suffix`: Append the component suffix to the generated class name.
-- `--force`: Set this flag to overwrite existing files on destination.
+- `--namespace` (`-n`): Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
+- `--suffix` (`-s`): Append the component suffix to the generated class name.
+- `--force` (`-f`): Set this flag to overwrite existing files on destination.
### make:controller
@@ -105,11 +112,11 @@ make:controller [options]
#### Options:
-- `--bare`: Extends from `CodeIgniter\Controller` instead of `BaseController`.
-- `--restful`: Extends from a RESTful resource. Choices are `controller` and `presenter`. Defaults to `controller`.
-- `--namespace`: Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
-- `--suffix`: Append the component suffix to the generated class name.
-- `--force`: Set this flag to overwrite existing files on destination.
+- `--bare` (`-b`): Extends from `CodeIgniter\Controller` instead of `BaseController`.
+- `--restful` (`-r`): Extends from a RESTful resource. Choices are `controller` and `presenter`. Defaults to `controller`.
+- `--namespace` (`-n`): Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
+- `--suffix` (`-s`): Append the component suffix to the generated class name.
+- `--force` (`-f`): Set this flag to overwrite existing files on destination.
!!! note "Note"
If you use `--suffix`, the generated controller name will be like `ProductController`. That violates the Controller naming convention when using [Auto Routing](../incoming/controllers.md#auto-routing-improved) (Controller class names MUST start with an uppercase letter and ONLY the first character can be uppercase). So `--suffix` can be used when you use [Defined Routes](../incoming/routing.md#setting-routing-rules).
@@ -130,9 +137,9 @@ make:entity [options]
#### Options:
-- `--namespace`: Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
-- `--suffix`: Append the component suffix to the generated class name.
-- `--force`: Set this flag to overwrite existing files on destination.
+- `--namespace` (`-n`): Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
+- `--suffix` (`-s`): Append the component suffix to the generated class name.
+- `--force` (`-f`): Set this flag to overwrite existing files on destination.
### make:filter
@@ -150,9 +157,9 @@ make:filter [options]
#### Options:
-- `--namespace`: Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
-- `--suffix`: Append the component suffix to the generated class name.
-- `--force`: Set this flag to overwrite existing files on destination.
+- `--namespace` (`-n`): Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
+- `--suffix` (`-s`): Append the component suffix to the generated class name.
+- `--force` (`-f`): Set this flag to overwrite existing files on destination.
### make:model
@@ -170,12 +177,32 @@ make:model [options]
#### Options:
-- `--dbgroup`: Database group to use. Defaults to `default`.
-- `--return`: Set the return type from `array`, `object`, or `entity`. Defaults to `array`.
-- `--table`: Supply a different table name. Defaults to the pluralized class name.
-- `--namespace`: Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
-- `--suffix`: Append the component suffix to the generated class name.
-- `--force`: Set this flag to overwrite existing files on destination.
+- `--table` (`-t`): Supply a different table name. Defaults to the pluralized class name.
+- `--dbgroup` (`-g`): Database group to use. Defaults to `default`.
+- `--return` (`-r`): Set the return type from `array`, `object`, or `entity`. Defaults to `array`.
+- `--namespace` (`-n`): Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
+- `--suffix` (`-s`): Append the component suffix to the generated class name.
+- `--force` (`-f`): Set this flag to overwrite existing files on destination.
+
+### make:request
+
+Creates a new FormRequest file.
+
+#### Usage:
+
+```
+make:request [options]
+```
+
+#### Argument:
+
+- `name`: The name of the FormRequest class. **[REQUIRED]**
+
+#### Options:
+
+- `--namespace` (`-n`): Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
+- `--suffix` (`-s`): Append the component suffix to the generated class name.
+- `--force` (`-f`): Set this flag to overwrite existing files on destination.
### make:seeder
@@ -193,9 +220,9 @@ make:seeder [options]
#### Options:
-- `--namespace`: Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
-- `--suffix`: Append the component suffix to the generated class name.
-- `--force`: Set this flag to overwrite existing files on destination.
+- `--namespace` (`-n`): Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
+- `--suffix` (`-s`): Append the component suffix to the generated class name.
+- `--force` (`-f`): Set this flag to overwrite existing files on destination.
### make:test
@@ -215,8 +242,8 @@ make:test [options]
#### Options:
-- `--namespace`: Set the root namespace. Defaults to `Tests`.
-- `--force`: Set this flag to overwrite existing files on destination.
+- `--namespace` (`-n`): Set the root namespace. Defaults to `Tests`.
+- `--force` (`-f`): Set this flag to overwrite existing files on destination.
### make:transformer
@@ -234,9 +261,9 @@ make:transformer [options]
#### Options:
-- `--namespace`: Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
-- `--suffix`: Append the component suffix to the generated class name.
-- `--force`: Set this flag to overwrite existing files on destination.
+- `--namespace` (`-n`): Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
+- `--suffix` (`-s`): Append the component suffix to the generated class name.
+- `--force` (`-f`): Set this flag to overwrite existing files on destination.
### make:migration
@@ -254,12 +281,14 @@ make:migration [options]
#### Options:
-- `--session`: Generate a migration file for database sessions.
-- `--table`: Set the table name to use for database sessions. Defaults to `ci_sessions`.
-- `--dbgroup`: Set the database group for database sessions. Defaults to `default` group.
-- `--namespace`: Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
-- `--suffix`: Append the component suffix to the generated class name.
-- `--force`: Set this flag to overwrite existing files on destination.
+- `--session`: Generate a migration file for database sessions. The `name` argument is ignored.
+- `--table` (`-t`): Set the table name to use for database sessions. Defaults to `ci_sessions`.
+- `--dbgroup` (`-g`): Set the database group for database sessions. Defaults to `default` group.
+- `--namespace` (`-n`): Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
+- `--suffix` (`-s`): Append the component suffix to the generated class name.
+
+!!! note "Note"
+ `make:migration` has no `--force` option, since the timestamped file name only collides with an existing file when the same name is generated twice within one second.
### make:validation
@@ -277,9 +306,9 @@ make:validation [options]
#### Options:
-- `--namespace`: Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
-- `--suffix`: Append the component suffix to the generated class name.
-- `--force`: Set this flag to overwrite existing files on destination.
+- `--namespace` (`-n`): Set the root namespace. Defaults to the value of `APP_NAMESPACE`.
+- `--suffix` (`-s`): Append the component suffix to the generated class name.
+- `--force` (`-f`): Set this flag to overwrite existing files on destination.
## Scaffolding a Complete Set of Stock Code
@@ -302,9 +331,15 @@ will create the following files:
To include an `Entity` class in the scaffolded files, just add `--return entity` to the command and it will be passed to the model generator.
+!!! note "Note"
+ Since v4.8.0, `make:scaffold` accepts the `-b`, `-t`, `-g`, `-n`, `-s`, and `-f` shortcuts. `--restful` and `--return` have no shortcut here, since both use `-r` in their own commands. When the class name is omitted on an interactive run, it is asked for once and reused for every generated file.
+
## GeneratorTrait
-All generator commands must use the `GeneratorTrait` to fully utilize its methods that are used in code generation.
+!!! danger "Deprecated since version 4.8.0"
+ `GeneratorTrait` is the legacy way of building generator commands, tied to the deprecated `BaseCommand`. New generator commands should extend `AbstractGeneratorCommand` instead. See [Modern Generator Commands](cli_modern_generators.md).
+
+Legacy generator commands use the `GeneratorTrait` to fully utilize its methods that are used in code generation.
## Declaring the Location of a Custom Generator Command Template
diff --git a/docs/cli/cli_library.md b/docs/cli/cli_library.md
index 68aec17a4f35..821643274d74 100644
--- a/docs/cli/cli_library.md
+++ b/docs/cli/cli_library.md
@@ -33,6 +33,8 @@ class MyCommand extends BaseCommand
The class is automatically initialized when the file is loaded the first time.
+Since v4.8.0 the static methods delegate to instance classes, which you can also use directly. See [The Classes Behind the Facade](#the-classes-behind-the-facade).
+
## Getting Input from the User
Sometimes you need to ask the user for more information. They might not have provided optional command-line arguments, or the script may have encountered an existing file and needs confirmation before overwriting. This is handled with the `prompt()` or `promptByKey()` method.
@@ -168,6 +170,57 @@ $hobbies = CLI::promptByMultipleKeys('Select your hobbies:', ['Playing game', 'S
!!! important "Important"
The method `promptByMultipleKeys()`, unlike `promptByKey()`, does not support named keys or validation.
+### select() and multiselect()
+
+!!! success "Available from version 4.8.0"
+
+`select()` and `multiselect()` on `Prompt` let the user pick with the arrow keys (or `j` and `k`), tick with the space bar, tick everything or nothing with `a`, and confirm with enter. `select()` returns the chosen key, and `multiselect()` returns the ticked options, keyed as in `$options`. Both take any keys, and long lists scroll:
+
+```php
+select('Which database driver?', [
+ 'MySQLi' => 'MySQL / MariaDB',
+ 'Postgre' => 'PostgreSQL',
+ 'SQLite3' => 'SQLite',
+], 'MySQLi');
+
+// Returns key => label for each ticked option, possibly none.
+$filters = $prompt->multiselect('Which filters should run on every request?', [
+ 'csrf' => 'CSRF',
+ 'honeypot' => 'Honeypot',
+ 'secureheaders' => 'Secure Headers',
+], ['csrf']);
+
+// Refuses to confirm until at least one option is ticked.
+$environments = $prompt->multiselect('Which environments get the new key?', [
+ 'development' => 'Development',
+ 'testing' => 'Testing',
+ 'production' => 'Production',
+], required: true);
+```
+
+Where keys cannot be read one by one, such as on Windows or when the input is piped, both prompts list the options by number and ask for the number, or numbers separated by commas. The filters question above then reads:
+
+```console
+Which filters should run on every request?
+ [1] CSRF
+ [2] Honeypot
+ [3] Secure Headers
+Numbers, separated by commas (0 for none) [1]:
+```
+
+Under WSL, PHP runs as on Linux and reads the keys.
+
+On a quiet output (see [Verbosity](spark_commands.md#verbosity)) both prompts return the defaults without asking, and a required `multiselect()` without a default throws a `CLIException`. They also throw it when piped input ends before a valid answer.
+
+Pressing Ctrl+C restores the terminal and then interrupts the process as usual.
+
## Providing Feedback
### write()
@@ -247,6 +300,8 @@ for ($i = 0; $i <= 10; $i++) {
}
```
+The next `write()` starts on a fresh line. Since v4.8.0, printed text that already ends in a line feed leaves the cursor at the start of a line, so that `write()` adds no blank line first.
+
### color()
While the `write()` command will write a single line to the terminal, ending it with an EOL character, you can use the `color()` method to make a string fragment that can be used in the same way, except that it will not force an EOL after printing. This allows you to create multiple outputs on the same row. Or, more commonly, you can use it inside of a `write()` method to create a string of a different color inside:
@@ -440,3 +495,214 @@ CLI::wait(0, false);
// wait for specified interval
CLI::wait($seconds, false);
```
+
+## The Classes Behind the Facade
+
+!!! success "Available from version 4.8.0"
+
+The static output methods delegate to instance classes, each with one job. `CLI::getOutput()` and `CLI::getTerminal()` return the shared instances, and modern commands reach the output through `$this->getOutput()` (see [Writing Output](cli_modern_commands.md#writing-output)):
+
+```php
+write('Hello', 'green');
+$output->error('Something went wrong.');
+
+// Tables and progress bars are built on the output.
+$output->write((new Table([[1, 'bar']], ['ID', 'Title']))->render());
+
+$bar = new ProgressBar($output);
+$bar->advanceTo(5, 10);
+$bar->advanceTo(10, 10);
+
+// An output of your own, here capturing everything written to it.
+$captured = new Output($io = new MockInputOutput());
+$captured->write('captured');
+
+echo $io->getOutput();
+```
+
+Mixing the static methods with the instance is safe, because both write to the same `Output` and share its line breaks.
+
+The prompt methods delegate to `Prompt`, which reads through an `Output`. `CLI::getPrompt()` returns one on the shared output, and modern commands reach one on the output of their run through `$this->getPrompt()`:
+
+```php
+ask('What is your favorite color?', ['red', 'blue']);
+$hobby = $prompt->askByKey('Pick a hobby:', ['Games', 'Sleep']);
+$hobbies = $prompt->askByMultipleKeys('Pick your hobbies:', ['Games', 'Sleep', 'Music']);
+
+// A prompt of your own, here with a validation instance of your choice.
+$prompt = new Prompt(CLI::getOutput(), single_service('validation'));
+$email = $prompt->ask('What is your email?', null, 'required|valid_email');
+```
+
+## CodeIgniter\CLI
+
+### Output
+
+The writer. It targets STDOUT, sends `error()` to STDERR, and remembers whether the cursor sits mid-line so that `write()` starts on a fresh line.
+
+#### \__construct([InputOutput $io = new InputOutput()[, Terminal $terminal = new Terminal()]])
+
+- **Parameters**
+ - **$io** `InputOutput` The transport the text is written through.
+ - **$terminal** `Terminal` The terminal consulted for color support.
+
+#### print(string $text = ''[, string $foreground = ''[, string $background = ''[, Verbosity $verbosity = Verbosity::Normal]]]): void
+
+#### write(string $text = ''[, string $foreground = ''[, string $background = ''[, Verbosity $verbosity = Verbosity::Normal]]]): void
+
+#### newLine([int $num = 1[, Verbosity $verbosity = Verbosity::Normal]]): void
+
+The same as the static methods of the same names, writing nothing when the level of the output is below `$verbosity`.
+
+#### deferNewLine(): void
+
+Writes a blank line before whatever is written next, and none if nothing follows. The prompts end with it, so a prompt that ends the run leaves no blank line behind.
+
+#### error(string $text[, string $foreground = 'light_red'[, string $background = '']]): void
+
+#### beep(int $num = 1): void
+
+#### clearScreen(): void
+
+The same as the static methods of the same names. `error()` writes at every level but `Verbosity::Silent`, and the other two only from `Verbosity::Normal` on.
+
+#### getVerbosity(): Verbosity
+
+#### setVerbosity(Verbosity $verbosity): void
+
+#### isQuiet(): bool
+
+#### isVerbose(): bool
+
+#### isVeryVerbose(): bool
+
+#### isDebug(): bool
+
+`isQuiet()` is true for the quiet and silent levels, and the others for their level or above. See [Writing at a Verbosity](cli_modern_commands.md#writing-at-a-verbosity).
+
+#### color(string $text, string $foreground[, string $background = ''[, string $format = '']]): string
+
+Colors the text when the target stream supports colors, and returns it unchanged otherwise. `print()`, `write()`, and `error()` color through this method, so a subclass overriding it changes the coloring of every write.
+
+#### wrap(string $text[, int $max = 0[, int $padLeft = 0]]): string
+
+The same as `CLI::wrap()`, measuring the terminal of this output.
+
+#### isDecorated(): bool
+
+Whether the target stream supports colors.
+
+#### forStderr(): static
+
+Returns a copy whose STDOUT writes target STDERR, which is what the framework swaps in while rendering an exception.
+
+#### getTerminal(): Terminal
+
+### Prompt
+
+Asks questions on the console, reading the answers through an `Output`.
+
+#### \__construct(Output $output[, ?ValidationInterface $validation = null[, $keys = null]])
+
+- **Parameters**
+ - **$output** `Output` The output the questions are written and read through.
+ - **$validation** `ValidationInterface|null` The validation that checks the answers, or `null` for an unshared instance of the `validation` service.
+ - **$keys** `resource|null` The stream of keypresses read by `select()` and `multiselect()`, or `null` for STDIN.
+
+#### ask(string $field[, array|string|null $options = null[, array|string|null $validation = null]]): string
+
+#### askByKey(array|string $text, array $options[, array|string|null $validation = null]): string
+
+#### askByMultipleKeys(string $text, array $options): array
+
+The same as `CLI::prompt()`, `CLI::promptByKey()`, and `CLI::promptByMultipleKeys()`.
+
+#### select(string $question, array $options[, int|string|null $default = null]): int|string
+
+- **Parameters**
+ - **$default** `int|string|null` The key highlighted at the start, or `null` for the first one.
+
+#### multiselect(string $question, array $options[, array $default = [][, bool $required = false]]): array
+
+- **Parameters**
+ - **$default** `array` The keys ticked at the start.
+ - **$required** `bool` Whether at least one option must be ticked.
+
+See [select() and multiselect()](#select-and-multiselect).
+
+### Verbosity
+
+The level of the output, as an `int`-backed enum valued as `SHELL_VERBOSITY`: `Silent` (-2), `Quiet` (-1), `Normal` (0), `Verbose` (1), `VeryVerbose` (2), and `Debug` (3).
+
+### Terminal
+
+The dimensions and capabilities of the terminal. The dimensions are read once and cached.
+
+#### getWidth(int $default = 80): int
+
+#### getHeight(int $default = 32): int
+
+#### generateDimensions(): void
+
+#### hasColorSupport($stream): bool
+
+#### streamSupports(string $function, $stream): bool
+
+The same as the static methods of the same names.
+
+### ProgressBar
+
+A ten-cell progress bar redrawn in place.
+
+#### \__construct(Output $output)
+
+#### advanceTo(float|int $step[, int $totalSteps = 10]): void
+
+Draws the bar for the step, capped at 100%, over the previous bar unless the step went backwards. A negative step or a total below one throws `InvalidArgumentException`, where `CLI::showProgress()` keeps tolerating them.
+
+#### clear(): void
+
+Rings the bell and starts a new bar on the next `advanceTo()`. This is what `CLI::showProgress(false)` does.
+
+### Table
+
+#### \__construct(array $tbody[, array $thead = []])
+
+- **Parameters**
+ - **$tbody** `array` List of rows.
+ - **$thead** `array` List of column headings.
+
+#### render(): string
+
+Returns the table as lines ending in `PHP_EOL`, ready for `Output::write()`.
+
+### Color
+
+The ANSI color codes, independent of any stream.
+
+#### apply(string $text, string $foreground[, string $background = ''[, string $format = '']]): string
+
+Wraps the text in the color codes, always. Throws `CLIException` for an unknown color.
+
+#### width(string $string): int
+
+Returns the display width of the text without its color codes, as `CLI::strlen()` does.
diff --git a/docs/cli/cli_modern_commands.md b/docs/cli/cli_modern_commands.md
new file mode 100644
index 000000000000..f8b098e4f4b5
--- /dev/null
+++ b/docs/cli/cli_modern_commands.md
@@ -0,0 +1,1152 @@
+# Modern Spark Commands
+
+!!! success "Available from version 4.8.0"
+
+Modern commands are a newer style of [spark commands](cli_commands.md). Instead of declaring metadata through class properties, modern commands describe themselves through a `#[Command]` attribute and build their argument/option surface inside a `configure()` method. The framework then parses the command line, applies the declared defaults, validates what was passed, and finally calls `execute()` with typed, validated values.
+
+Modern and legacy commands can coexist: existing `BaseCommand` classes continue to work, and the framework routes invocations to whichever command matches the requested name, regardless of style. `BaseCommand` itself is deprecated, though. See [Migrating From BaseCommand](#migrating-from-basecommand).
+
+## Creating a Command
+
+A modern command is a class that:
+
+- extends `CodeIgniter\CLI\AbstractCommand`;
+- declares a `#[Command]` attribute with a `name`, a `description` and a `group`;
+- implements `execute(array $arguments, array $options): int` and returns an `EXIT_*` status code.
+
+A minimal example:
+
+```php
+getOutput()->write('Hello!', 'green');
+
+ return EXIT_SUCCESS;
+ }
+}
+```
+
+### File Location
+
+The rule is the same as for the legacy style: commands must live under a directory named **Commands** that is reachable through PSR-4 autoloading, for instance **app/Commands/**. The framework auto-discovers them the first time the command runner is used.
+
+### The `#[Command]` Attribute
+
+The attribute holds the command's identity:
+
+- `name` is the token users type after `php spark`. It must not be empty, must not contain whitespace, and may use a colon to namespace related commands (`cache:clear`, `make:migration`). Leading, trailing, or consecutive colons are rejected.
+- `description` is shown in the `list` output and at the top of `help `.
+- `group` controls how the command is grouped in the `list` output. A command with an empty `group` is skipped by discovery.
+- `aliases` is an optional list of alternative names the command can also be invoked by. Each alias follows the same naming rules as `name` and must differ from it. Aliases resolve to the command at dispatch (`php spark ` and `help ` both work), are listed as their own rows in the `list` output, and are shown in an `Aliases:` section of `help `.
+- `hidden` is an optional flag, `false` by default, that keeps the command out of listings. See [Hidden Commands](#hidden-commands).
+- `headerless` is an optional flag, `false` by default, that stops spark from printing its header before the command runs. See [Commands Without the Header](#commands-without-the-header).
+
+The attribute itself validates these constraints at construction time. If you misspell `name`, you will see the error at discovery rather than at run time. An alias that collides with an existing command name or with another command's alias is a hard error at discovery, since the runner could not tell which command you meant.
+
+```php
+getOutput()->write(json_encode(['status' => 'ok'], JSON_THROW_ON_ERROR));
+
+ return EXIT_SUCCESS;
+ }
+}
+```
+
+This has the same effect as always passing `--no-header`, and it covers the command's aliases too. `php spark help app:status` and `php spark app:status --help` still print the header, since help output is meant for people.
+
+To check the flag in code, use `isHeaderless()` on a command instance, or `Commands::isHeaderlessCommand()` with a command name or alias.
+
+## Command Lifecycle
+
+When the runner invokes a modern command, it walks through several phases in this order:
+
+1. **Construction.** The `#[Command]` attribute is read, then your `configure(): void` hook runs so you can register arguments, options, and extra usage examples. A default `--help` / `-h` flag, `--no-header` flag, `--no-interaction` / `-N` flag, `--silent` flag, `--quiet` / `-q` flag, and `--verbose` / `-v` flag are added automatically afterwards.
+2. `isAvailable(): bool` is called to check whether the command should execute (see [Restricting Command Execution](#restricting-command-execution)).
+3. `initialize(array &$arguments, array &$options): void` receives the raw arguments and options by reference. Useful when your command needs to massage input — for instance, to unfold an alias argument into the canonical form before anything else runs.
+4. `interact(array &$arguments, array &$options): void` also receives the raw arguments and options by reference. This is where you prompt the user for missing input, set values conditionally, or abort early. This hook is skipped when the command is non-interactive (see [Non-Interactive Mode](#non-interactive-mode)).
+5. **Bind & validate.** The framework maps the raw input to the definitions you declared in `configure()`, applies defaults, and rejects input that violates the definitions (missing required argument, unknown option, array option passed without a value, and so on).
+6. `execute(array $arguments, array $options): int` receives the bound and validated arguments and options, and returns an exit code.
+
+You only have to implement `execute()`; the other hooks are optional.
+
+## Arguments
+
+Arguments are positional — the first token after the command name is bound to the first declared argument, the second token to the second declared argument, and so on. They are declared inside `configure()` using the `CodeIgniter\CLI\Input\Argument` value object:
+
+```php
+addArgument(new Argument(
+ name: 'name',
+ description: 'Who to greet.',
+ required: true,
+ ))
+ // Optional — a default is mandatory.
+ ->addArgument(new Argument(
+ name: 'salutation',
+ description: 'Optional salutation.',
+ default: 'Hello',
+ ))
+ // Array — collects every remaining token. Must be declared last.
+ ->addArgument(new Argument(
+ name: 'extras',
+ description: 'Any extra tokens.',
+ isArray: true,
+ ));
+ }
+
+ protected function execute(array $arguments, array $options): int
+ {
+ // ...
+
+ return EXIT_SUCCESS;
+ }
+}
+```
+
+The following rules are enforced at configuration time. Violating any of them raises an `InvalidArgumentDefinitionException`:
+
+- A required argument **must not** have a default value.
+- An optional argument **must** have a default value.
+- An array argument collects every remaining positional token. Only one array argument may be declared, and it must come last.
+- An array argument cannot be required (but it can have a non-empty default).
+- Required arguments must all come before optional arguments.
+- Argument names must match `[A-Za-z0-9_-]+` and the name `extra_arguments` is reserved.
+
+## Options
+
+Options are name-based. They are declared with `CodeIgniter\CLI\Input\Option`:
+
+```php
+addOption(new Option(
+ name: 'dry-run',
+ shortcut: 'r',
+ description: 'Show what would be published without copying.',
+ ))
+ // Value-required. --destination=path or -d path. A string default is mandatory.
+ ->addOption(new Option(
+ name: 'destination',
+ shortcut: 'd',
+ description: 'Destination folder.',
+ requiresValue: true,
+ default: 'public',
+ ))
+ // Value-optional. Both `--driver` and `--driver=redis` are accepted.
+ ->addOption(new Option(
+ name: 'driver',
+ description: 'Optional driver override.',
+ acceptsValue: true,
+ ))
+ // Array. `--tag=a --tag=b` collects to ['a', 'b']. Array options must require a value.
+ ->addOption(new Option(
+ name: 'tag',
+ shortcut: 't',
+ description: 'Tag to publish (may be repeated).',
+ requiresValue: true,
+ isArray: true,
+ ))
+ // Negatable. Both --clean and --no-clean are registered automatically.
+ ->addOption(new Option(
+ name: 'clean',
+ description: 'Clean the destination first.',
+ negatable: true,
+ default: true,
+ ));
+ }
+
+ protected function execute(array $arguments, array $options): int
+ {
+ // ...
+
+ return EXIT_SUCCESS;
+ }
+}
+```
+
+Options support the following modes (they can be combined where it makes sense):
+
+- **Flag** — the default. The option takes no value. Presence makes the bound value `true`; absence leaves it `false`.
+- `requiresValue: true` — the option must be followed by a value when passed.
+- `acceptsValue: true` — the option may be followed by a value, but the value is optional.
+- `isArray: true` — the option may be passed multiple times; each value is appended to the bound array.
+- `negatable: true` — a second long form `--no-` is registered automatically. Passing `--name` sets the option to `true`; passing `--no-name` sets it to `false`.
+
+Every option may also declare a single-character `shortcut` (e.g., `-f` for `--force`). Shortcuts must be a single alphanumeric character and unique within the command.
+
+A few quirks are worth knowing:
+
+- `requiresValue: true` and `isArray: true` both imply `acceptsValue: true`.
+- An option that requires a value must be given a **string** default. The default is used only when the option is not passed at all; passing the option without a value throws at validation.
+- An array option must require a value. Its default must be `null` or a non-empty array (`null` is normalized to an empty array internally).
+- A negatable option cannot accept a value or be an array. Its default must be a boolean.
+- A negatable option's auto-generated `--no-` form will clash if another option is already named `no-`.
+- Option names must match `[A-Za-z0-9_-]+` and the name `extra_options` is reserved.
+- The following options are reserved for the framework and registered on every command automatically:
+ - `--help` / `-h`
+ - `--no-header`
+ - `--no-interaction` / `-N`
+ - `--silent`
+ - `--quiet` / `-q`
+ - `--verbose` / `-v`
+
+Configuration-time violations raise `InvalidOptionDefinitionException`.
+
+!!! note "Note"
+ The command-line parser does **not** understand bundled shortcuts or shortcuts with a glued value:
+
+ - `-abc` is read as one option named `abc`, *not* as `-a -b -c`. The only exception is a run of `v`, such as `-vvv`, which sets the verbosity.
+ - `-fvalue` is read as one option named `fvalue`; it is not split into shortcut `-f` with value `value`.
+
+ Pass shortcut values with `-f=value` or `-f value` instead.
+
+## Interacting With the User
+
+`interact()` is designed for commands that need to prompt, confirm, or fill in missing input before validation runs. Its `$arguments` and `$options` parameters are **raw** — they are the tokens the framework parsed from the command line, *before* the values are mapped to your declared definitions.
+
+Because the raw input may be keyed by the long name, the shortcut, or the negation form, two helpers make lookups alias-aware:
+
+- `hasUnboundOption(string $name, ?array $options = null): bool`
+- `getUnboundOption(string $name, ?array $options = null): array|string|null`
+
+Inside `initialize()` and `interact()` pass `$options` explicitly — the instance snapshot is not populated yet. From `execute()` (or any helper reached from it) you can omit `$options` and the helpers will read from the snapshot taken right after `interact()` returns and before bind and validate.
+
+```php
+addOption(new Option(
+ name: 'force',
+ shortcut: 'f',
+ description: 'Skip the confirmation prompt.',
+ ));
+ }
+
+ protected function interact(array &$arguments, array &$options): void
+ {
+ // hasUnboundOption() resolves --force, -f, and --no-force in one call,
+ // even though $options here is still the raw parsed input.
+ if ($this->hasUnboundOption('force', $options)) {
+ return;
+ }
+
+ if ($this->getPrompt()->ask('Delete the logs?', ['n', 'y']) === 'n') {
+ return;
+ }
+
+ // Mutations made here flow through to bind(), validate(), and execute().
+ // For a flag option, writing `null` models "the flag was passed".
+ $options['force'] = null;
+ }
+
+ protected function execute(array $arguments, array $options): int
+ {
+ if ($this->getValidatedOption('force') === false) {
+ $this->getOutput()->error('Aborted.');
+
+ return EXIT_ERROR;
+ }
+
+ // ... actually delete the logs ...
+
+ return EXIT_SUCCESS;
+ }
+}
+```
+
+Any change you make to `$arguments` or `$options` inside `interact()` carries through to bind, validate, and `execute()`.
+
+### Prompting for Missing Arguments
+
+A command can let the framework prompt for its missing required arguments instead of hand-rolling that logic in `interact()`. Opt in by implementing the `CodeIgniter\CLI\PromptsForMissingInputInterface` marker interface:
+
+```php
+addArgument(new Argument(
+ name: 'name',
+ description: 'Who to greet.',
+ required: true,
+ ));
+ }
+
+ protected function getArgumentPromptLabels(): array
+ {
+ return ['name' => 'Who should be greeted?'];
+ }
+
+ protected function execute(array $arguments, array $options): int
+ {
+ $this->getOutput()->write(sprintf('Hello, %s!', $arguments['name']), 'green');
+
+ return EXIT_SUCCESS;
+ }
+}
+```
+
+On an interactive run, right before `interact()` is called, the framework compares the provided positional tokens against the declared required arguments and prompts (with the `required` validation rule) for each one still missing, in declaration order. Two hooks refine the behavior:
+
+- `getArgumentPromptLabels(): array` returns a map of argument name to prompt label. Arguments without an entry fall back to a generic label naming the argument.
+- `afterPrompting(array &$arguments, array &$options): void` runs after at least one prompt occurred. Useful to derive further input from the answers.
+
+On a non-interactive run nothing is prompted, and a missing required argument fails validation with the usual missing-arguments error. Since the prompted values are appended before the raw input is snapshotted, they are also visible to `interact()`, the unbound accessors, and anything forwarded through `$this->call(...)`.
+
+### Non-Interactive Mode
+
+Every modern command accepts `--no-interaction` / `-N` out of the box. When the flag is present, or when the command is otherwise non-interactive, the `interact()` hook is skipped entirely and the command proceeds straight to bind, validate, and `execute()`.
+
+Programmatically, the state is exposed through two public methods on `AbstractCommand`:
+
+- `isInteractive(): bool`: reports the current state.
+- `setInteractive(bool $interactive): static`: pins the state, overriding both the CLI flag and TTY detection. Returns `$this` for chaining.
+
+The resolved state follows this precedence:
+
+1. An explicit `setInteractive(bool)` call wins. Useful when a command must force a specific mode for safety.
+2. Otherwise, the CLI flag `--no-interaction` / `-N` forces non-interactive state.
+3. Otherwise, **STDIN** is probed: if it is not a TTY (piped input, cron, CI, `nohup`), the command is non-interactive.
+4. Otherwise, the command is interactive.
+
+When the current command invokes another via `$this->call(...)`, the parent's non-interactive state is propagated to the sub-command automatically. A caller that passes `no-interaction` (or `N`) in the sub-command's `$options` wins over that propagation.
+
+The propagation can be overridden with the `$noInteractionOverride` parameter of `call()`:
+
+- `null` (default): propagate the parent's state.
+- `true`: force the sub-command non-interactive regardless of the parent.
+- `false`: remove any forwarded `--no-interaction` / `-N` from the child `$options` so the sub-command resolves its own state. Note: TTY detection can still downgrade the sub-command if STDIN is not a TTY.
+
+## Restricting Command Execution
+
+Sometimes a command should not run in a specific runtime context. For example, development-only commands may need to be blocked in the `production` environment.
+
+You can override `isAvailable()` to decide at runtime whether the command may execute. By default, this method returns `true`.
+
+```php
+addArgument(new Argument(name: 'name', required: true))
+ ->addOption(new Option(name: 'loud', negatable: true, default: false));
+ }
+
+ protected function execute(array $arguments, array $options): int
+ {
+ // Directly from the parameters:
+ $name = $arguments['name'];
+
+ // Or via the validated accessors — throws LogicException if the name
+ // is not declared on this command:
+ $name = $this->getValidatedArgument('name');
+ $loud = $this->getValidatedOption('loud');
+
+ // Need to know whether --loud was actually passed, not just whether it
+ // resolved to its declared default? Use the unbound accessors:
+ $loudWasPassed = $this->hasUnboundOption('loud');
+
+ // ...
+
+ return EXIT_SUCCESS;
+ }
+}
+```
+
+Accessors that take a name throw `LogicException` when the name is not declared on the command.
+
+## Writing Output
+
+`$this->getOutput()` returns the `Output` of the current run. It is the same instance written to by the static `CLI` methods, so the two can be mixed and the line breaks stay consistent. `Output` carries the writing methods: `write()`, `print()`, `error()`, `newLine()`, `beep()`, `clearScreen()`, and `color()`. Tables and progress bars are rendered by `Table` and `ProgressBar`:
+
+```php
+getOutput();
+
+ $output->write('Generating the report...', 'yellow');
+
+ $rows = $this->collectRows();
+
+ if ($rows === []) {
+ $output->error('Nothing to report.');
+
+ return EXIT_ERROR;
+ }
+
+ $output->write((new Table($rows, ['ID', 'Title']))->render());
+ $output->write(sprintf('%d rows.', count($rows)), 'green');
+
+ return EXIT_SUCCESS;
+ }
+
+ /**
+ * @return list
+ */
+ private function collectRows(): array
+ {
+ return [[1, 'First'], [2, 'Second']];
+ }
+}
+```
+
+By default the run writes to the output shared through `CLI::getOutput()`. Pass an `Output` as the third argument of `run()` to write somewhere else, for instance to a `MockInputOutput` in a test. See [CLI Library](../cli/cli_library.md) for the classes and [Testing CLI Output](../testing/cli.md#testing-cli-output) for testing.
+
+### Writing at a Verbosity
+
+**spark** sets the `Verbosity` of the output from `--silent`, `--quiet` / `-q`, and `--verbose` / `-v`, `-vv`, `-vvv` (see [Verbosity](spark_commands.md#verbosity)). `print()`, `write()`, and `newLine()` take the lowest level that shows the text as their last parameter, and `isVerbose()`, `isVeryVerbose()`, `isDebug()`, and `isQuiet()` guard larger blocks:
+
+```php
+getOutput();
+ $rows = [[1, 'First'], [2, 'Second']];
+
+ $output->write('Connecting to the remote...', verbosity: Verbosity::Verbose);
+ $output->write(sprintf('%d records synchronized.', count($rows)), 'green');
+
+ if ($output->isVeryVerbose()) {
+ $output->write((new Table($rows, ['ID', 'Title']))->render());
+ }
+
+ return EXIT_SUCCESS;
+ }
+}
+```
+
+`--quiet` drops every write at the normal level, so only errors and text written at `Verbosity::Quiet` remain, and it makes the run non-interactive.
+
+A command run through `call()` writes at the level of the calling command. Use `callSilently()` to hide its output.
+
+## Calling Another Command
+
+Inside `execute()`, a modern command can invoke another modern command through `$this->call()`. `call()` must not be used from `configure()`, `initialize()`, or `interact()` — the current command has not been bound and validated yet at those points, and its unbound state has not been snapshotted.
+
+```php
+call('cache:clear', arguments: ['file']);
+
+// ...and/or with options. Use `null` for a flag's value to model "the flag was passed".
+$this->call('logs:clear', options: ['force' => null]);
+```
+
+The `$arguments` and `$options` you pass are interpreted as raw input — they go through bind and validate on the target command, just like a user invocation.
+
+To forward the caller's own input through to the target command, pass `$this->getUnboundArguments()` and `$this->getUnboundOptions()` to `call()`:
+
+```php
+call('cache:clear', $this->getUnboundArguments(), $this->getUnboundOptions());
+```
+
+### Calling Silently
+
+When a command delegates a step to another command but wants to emit its own consolidated message instead of letting the sub-command's output leak through, use `$this->callSilently()`:
+
+```php
+callSilently('cache:clear');
+
+if ($exitCode === EXIT_SUCCESS) {
+ $this->getOutput()->write('Cache cleared as part of deploy step.', 'green');
+}
+```
+
+The sub-command's output is suppressed and `$noInteractionOverride` defaults to `true`, since a silenced sub-command cannot meaningfully prompt. Pass an explicit value to override.
+
+While the sub-command runs, `CLI::getOutput()` and `$this->getOutput()` return an output backed by `NullInputOutput`, so everything written through either, including the sub-command's own `callSilently()` or `call()` sub-commands, is discarded. The previous output is restored afterwards.
+
+## Usage Examples
+
+The default usage line is built automatically from the command name and the declared argument list. You can append additional example lines by calling `addUsage()` inside `configure()`:
+
+```php
+addArgument(new Argument(name: 'name', required: true))
+ // The default usage line is always generated from the declared
+ // arguments; these extra lines are appended after it.
+ ->addUsage('app:greet Alice')
+ ->addUsage('app:greet "Bob the Builder"');
+ }
+
+ protected function execute(array $arguments, array $options): int
+ {
+ // ...
+
+ return EXIT_SUCCESS;
+ }
+}
+```
+
+In the `help ` or ` --help` output the default usage line is shown first, followed by each `addUsage()` entry in the order it was added.
+
+## Rendering an Exception
+
+If your command catches a `Throwable` and wants to produce the same formatted output the framework uses for uncaught exceptions, call `$this->renderThrowable($exception)`. The helper is safe to call from any command, and it will not disturb the currently shared request.
+
+## Migrating From `BaseCommand`
+
+The modern command system is a superset of the legacy `BaseCommand` API — the same capabilities are there, just expressed through an attribute and explicit definitions rather than class properties and ad-hoc lookups.
+
+**Identity**
+
+`protected $name`
+Moves to `name:` on the `#[Command]` attribute.
+
+`protected $description`
+Moves to `description:` on the `#[Command]` attribute.
+
+`protected $group`
+Moves to `group:` on the `#[Command]` attribute. An empty group skips the command at discovery.
+
+**Input surface (declare inside** `configure()` **)**
+
+`protected $usage`
+The default usage line is generated from the declared arguments. Register extras with `addUsage()`.
+
+`protected $arguments`
+One `addArgument(new Argument(...))` call per argument.
+
+`protected $options`
+One `addOption(new Option(...))` call per option. A long name is required; a legacy `-x`-style option becomes `new Option(name: 'something', shortcut: 'x')`.
+
+**Runtime**
+
+`run(array $params)`
+No longer the extension point — `run()` is `final` on `AbstractCommand` and drives the lifecycle itself. Move the body into `execute(array $arguments, array $options): int`, which must return an `EXIT_*` status.
+
+`$params[0]`
+Use `$arguments['name']` or `$this->getValidatedArgument('name')`.
+
+`$params['name']` / `CLI::getOption('name')`
+Use `$options['name']` or `$this->getValidatedOption('name')`. Call `$this->hasUnboundOption('name')` when you need to know whether the flag was actually passed.
+
+`$this->call('other', $params)`
+Becomes `$this->call('other', $arguments, $options)`; only from inside `execute()`. To forward the caller's own raw input, pass `$this->getUnboundArguments()` and `$this->getUnboundOptions()`.
+
+`$this->showError($e)`
+Becomes `$this->renderThrowable($e)`.
+
+`CLI::write()`, `CLI::error()`, and the other writers
+Use the same methods on `$this->getOutput()`. See [Writing Output](#writing-output).
+
+`showHelp()` override
+Gone. The built-in `help` command builds the help output itself from the declared arguments, options, and usages.
+
+Prompting the user goes through `$this->getPrompt()`, and the idiomatic spot moves from `run()` to `interact()` so validation can see whatever the user provides interactively.
+
+A typical `BaseCommand` implementation:
+
+```php
+] [options]';
+ protected $arguments = ['target' => 'What to publish.'];
+ protected $options = [
+ '--force' => 'Overwrite existing output.',
+ '--dry-run' => 'Print the plan without writing anything.',
+ ];
+
+ public function run(array $params)
+ {
+ try {
+ $target = $params[0] ?? CLI::prompt('What should I publish?', null, 'required');
+
+ $this->publish($target);
+
+ return EXIT_SUCCESS;
+ } catch (Throwable $e) {
+ $this->showError($e);
+
+ return EXIT_ERROR;
+ }
+ }
+
+ private function publish(string $target): void
+ {
+ // Option values come from CLI's global state, so the sub-method still
+ // has to thread the positional $target through but can reach the
+ // named options without extra parameters.
+ $force = CLI::getOption('force') !== null;
+ $dryRun = CLI::getOption('dry-run') !== null;
+
+ // ...
+ unset($force, $dryRun);
+
+ CLI::write(sprintf('publishing %s', $target), 'green');
+ }
+}
+```
+
+…becomes, as a modern command:
+
+```php
+addArgument(new Argument(name: 'target', description: 'What to publish.', required: true))
+ ->addOption(new Option(name: 'force', description: 'Overwrite existing output.'))
+ ->addOption(new Option(name: 'dry-run', description: 'Print the plan without writing anything.'));
+ }
+
+ protected function interact(array &$arguments, array &$options): void
+ {
+ if ($arguments === []) {
+ $arguments[] = $this->getPrompt()->ask('What should I publish?', null, 'required');
+ }
+ }
+
+ protected function execute(array $arguments, array $options): int
+ {
+ try {
+ $this->publish($arguments['target']);
+
+ return EXIT_SUCCESS;
+ } catch (Throwable $e) {
+ $this->renderThrowable($e);
+
+ return EXIT_ERROR;
+ }
+ }
+
+ private function publish(string $target): void
+ {
+ // Unlike the legacy version, the sub-method can reach the validated
+ // options through the helpers without threading $options through.
+ $force = $this->getValidatedOption('force') === true;
+ $dryRun = $this->getValidatedOption('dry-run') === true;
+
+ // ...
+ unset($force, $dryRun);
+
+ $this->getOutput()->write(sprintf('publishing %s', $target), 'green');
+ }
+}
+```
+
+Two behavioral changes are worth calling out explicitly:
+
+- **Validated, not raw.** Arguments and options are parsed, defaulted, and validated before `execute()` runs. If a required argument is missing or a `requiresValue` option was passed without a value, the framework raises a typed exception and your command is never entered.
+- **Exit codes are mandatory.** Legacy `run()` could return `null`. The modern `execute()` must return an integer; the framework emits a deprecation notice for any legacy command that still returns `null`.
+
+## Coexistence With Legacy Commands
+
+Legacy `BaseCommand` classes are still supported, and they are discovered alongside modern commands. If the same name is claimed by both a legacy and a modern command, the legacy one is invoked and a warning is printed to STDERR once at discovery time so you can rename or retire one of the two. Any aliases declared by the shadowed modern command are dropped at discovery, so they are neither listed nor runnable. Resolve the collision and the modern command, along with its aliases, becomes reachable again.
+
+To detect the collision programmatically — for example, in a migration script that verifies the legacy copy was removed — the `Commands` runner exposes two read-only checks:
+
+```php
+hasLegacyCommand('foo') && $commands->hasModernCommand('foo')) {
+ // Both registries claim the name; the legacy version will run.
+}
+```
+
+The `help` command understands both styles — it delegates to the legacy `showHelp()` method for legacy commands and renders a structured view for modern ones.
+
+A legacy command can call a modern one through `$this->call()`: integer-keyed params become positional arguments and string-keyed params become options, and the modern command validates them like any other input. Unknown options or extra arguments that a legacy target would have ignored are rejected.
+
+!!! danger "Deprecated since version 4.8.0"
+ `BaseCommand` is deprecated, and running a legacy command triggers a deprecation notice (see [Logging Deprecation Warnings](../general/errors.md#logging-deprecation-warnings)). New commands should be written against `AbstractCommand` from the start.
+
+## AbstractCommand
+
+The `AbstractCommand` class that all modern commands must extend exposes a number of utility methods you call from within your own command. Hooks like `configure()`, `initialize()`, `interact()`, and `execute()` are covered in the sections above and are not listed here.
+
+## CodeIgniter\CLI
+
+### AbstractCommand
+
+#### getCommandRunner(): Commands
+
+Returns the `Commands` runner the command was constructed with. Useful when you need to introspect other discovered commands (for instance, building a custom `list`-style command).
+
+#### getName(): string
+
+Returns the command name declared on the `#[Command]` attribute.
+
+#### getDescription(): string
+
+Returns the command description declared on the `#[Command]` attribute.
+
+#### getGroup(): string
+
+Returns the command group declared on the `#[Command]` attribute.
+
+#### isHidden(): bool
+
+Returns whether the `#[Command]` attribute marks the command as hidden. See [Hidden Commands](#hidden-commands).
+
+#### isHeaderless(): bool
+
+Returns whether the `#[Command]` attribute opts the command out of the header. See [Commands Without the Header](#commands-without-the-header).
+
+#### getUsages(): array
+
+Returns every usage line registered for the command — the default line built from the argument list, followed by each `addUsage()` entry in declaration order.
+
+#### getArgumentsDefinition(): array
+
+Returns the `Argument` value objects registered on this command, keyed by argument name and ordered by declaration.
+
+#### getOptionsDefinition(): array
+
+Returns the `Option` value objects registered on this command, keyed by option name.
+
+#### getShortcuts(): array
+
+Returns the shortcut-to-option-name map (for example `['f' => 'force']`). Empty when no shortcut is declared.
+
+#### getNegations(): array
+
+Returns the negation-to-option-name map (for example `['no-force' => 'force']`). Empty when no negatable option is declared.
+
+#### addUsage(string $usage): static
+
+- **Parameters**
+ - **$usage** `string` An extra usage example line.
+
+Adds a usage example to the `help ` output. The default usage line derived from the argument list is always shown first.
+
+#### addArgument(Argument $argument): static
+
+- **Parameters**
+ - **$argument** `Argument` The argument definition to register.
+
+Registers a positional argument. Call from `configure()`.
+
+#### addOption(Option $option): static
+
+- **Parameters**
+ - **$option** `Option` The option definition to register.
+
+Registers an option. Call from `configure()`.
+
+#### renderThrowable(Throwable $e): void
+
+- **Parameters**
+ - **$e** `Throwable` The throwable to render.
+
+Produces the same formatted output on STDERR that the framework uses for uncaught exceptions. Safe to call from any command.
+
+#### hasArgument(string $name): bool
+
+- **Parameters**
+ - **$name** `string` The argument name to look up.
+
+Returns `true` if an argument with that name is declared on the command.
+
+#### hasOption(string $name): bool
+
+- **Parameters**
+ - **$name** `string` The option name to look up.
+
+Returns `true` if an option with that name is declared on the command.
+
+#### hasShortcut(string $shortcut): bool
+
+- **Parameters**
+ - **$shortcut** `string` The shortcut character to look up.
+
+Returns `true` if the shortcut is claimed by one of the declared options.
+
+#### hasNegation(string $name): bool
+
+- **Parameters**
+ - **$name** `string` The negation name (for example `no-force`) to look up.
+
+Returns `true` if the negation is registered by one of the declared options.
+
+#### isInteractive(): bool
+
+Reports whether the command will prompt the user. See [Non-Interactive Mode](#non-interactive-mode) for the resolution order.
+
+#### setInteractive(bool $interactive): static
+
+- **Parameters**
+ - **$interactive** `bool` The state to pin.
+
+- **Returns**: The current command instance for chaining.
+
+Overrides both the `--no-interaction` / `-N` flag and TTY detection for this command instance. Typically called from `initialize()` or by an outer caller.
+
+#### run(array $arguments, array $options[, ?Output $output = null]): int
+
+- **Parameters**
+ - **$arguments** `array` The raw positional arguments parsed from the command line.
+ - **$options** `array` The raw option map parsed from the command line.
+ - **$output** `Output|null` The output to run against, or `null` for `CLI::getOutput()`.
+
+- **Returns**: The exit code returned by `execute()`.
+
+**Final.** Walks the command through `initialize()`, `interact()`, bind, validate, and finally `execute()`. The framework calls this on your behalf — you rarely invoke it directly, but you can when driving a command manually (for instance, from a test).
+
+A passed `$output` replaces `CLI::getOutput()` for the duration of the run, so sub-commands called with `call` inherit it. The previous output is restored afterwards, also when the run throws, so an uncaught exception is rendered on the previous output.
+
+#### getOutput(): Output
+
+Returns the `Output` of the current run, which is the same instance written to by the static `CLI` methods. See [Writing Output](#writing-output).
+
+#### getPrompt(): Prompt
+
+Returns a `Prompt` reading through the output of the current run. See [The Classes Behind the Facade](cli_library.md#the-classes-behind-the-facade). Override it to return a `Prompt` subclass, which then also asks for missing arguments (see [Prompting for Missing Arguments](#prompting-for-missing-arguments)) and the generators' confirmation.
+
+#### call(string $command[, array $arguments = [], array $options = [], ?bool $noInteractionOverride = null]): int
+
+- **Parameters**
+ - **$command** `string` The name of the modern command to call.
+ - **$arguments** `array` Positional arguments to forward.
+ - **$options** `array` Options to forward, keyed by long name, shortcut, or negation.
+ - **$noInteractionOverride** `bool|null` Override the sub-command's interactive state. `null` propagates the parent's state (default); `true` forces non-interactive; `false` removes any forwarded `--no-interaction` from `$options`.
+
+- **Returns**: The exit code returned by the called command.
+
+Invokes another modern command. The arguments and options go through bind and validate on the target command, just like a user invocation.
+
+#### callSilently(string $command[, array $arguments = [], array $options = [], ?bool $noInteractionOverride = true]): int
+
+- **Parameters**
+ - **$command** `string` The name of the modern command to call.
+ - **$arguments** `array` Positional arguments to forward.
+ - **$options** `array` Options to forward, keyed by long name, shortcut, or negation.
+ - **$noInteractionOverride** `bool|null` See `call`. Defaults to `true`.
+
+- **Returns**: The exit code returned by the called command.
+
+Like `call`, but suppresses the sub-command's output.
+
+#### getUnboundArguments(): array
+
+Returns the raw, parsed positional arguments as passed to the command.
+
+#### getUnboundArgument(int $index): string
+
+- **Parameters**
+ - **$index** `int` The zero-based index of the argument to read.
+
+Returns a single raw positional argument. Throws `LogicException` when the index does not exist.
+
+#### getUnboundOptions(): array
+
+Returns the raw, parsed option map, keyed by long name, shortcut, or negation.
+
+#### getUnboundOption(string $name[, array|null $options = null]): array|string|null
+
+- **Parameters**
+ - **$name** `string` The declared option name to look up.
+ - **$options** `array|null` Raw option map to read from. Required inside `initialize()` and `interact()`, optional from `execute()` onwards.
+
+Returns the raw value the option was given, resolving its shortcut and negation. Returns `null` when the option was not provided — callers can use the `??` operator to supply a fallback, or `hasUnboundOption` to disambiguate presence from a `null` value. Throws `LogicException` when the option is not declared on this command.
+
+#### hasUnboundOption(string $name[, array|null $options = null]): bool
+
+- **Parameters**
+ - **$name** `string` The declared option name to look up.
+ - **$options** `array|null` Raw option map to read from. Required inside `initialize()` and `interact()`, optional from `execute()` onwards.
+
+Returns `true` if the option was provided under its long name, shortcut, or negation. Throws `LogicException` when the option is not declared on this command.
+
+#### getValidatedArguments(): array
+
+Returns the bound and validated arguments, keyed by declared name.
+
+#### getValidatedArgument(string $name): array|string
+
+- **Parameters**
+ - **$name** `string` The declared argument name to read.
+
+Returns the bound and validated value for a single argument. Throws `LogicException` when the argument is not declared on this command.
+
+#### getValidatedOptions(): array
+
+Returns the bound and validated options, keyed by declared name.
+
+#### getValidatedOption(string $name): bool|array|string|null
+
+- **Parameters**
+ - **$name** `string` The declared option name to read.
+
+Returns the bound and validated value for a single option. Throws `LogicException` when the option is not declared on this command.
diff --git a/docs/cli/cli_modern_generators.md b/docs/cli/cli_modern_generators.md
new file mode 100644
index 000000000000..69f4c1da0bba
--- /dev/null
+++ b/docs/cli/cli_modern_generators.md
@@ -0,0 +1,404 @@
+# Modern Generator Commands
+
+!!! success "Available from version 4.8.0"
+
+Generator commands are spark commands that create files from templates. The built-in `make:*` commands documented in [CLI Generators](cli_generators.md) are the canonical examples. The modern counterpart of the legacy `GeneratorTrait` is the `AbstractGeneratorCommand` base class paired with a `#[GeneratorCommand]` attribute: the attribute describes *what* to generate, and the base class carries the whole generation pipeline so that a trivial generator needs no method body at all.
+
+!!! note "Note"
+ `GeneratorTrait` is deprecated, but it continues to work for legacy `BaseCommand` generators. See [Migrating From GeneratorTrait](#migrating-from-generatortrait).
+
+## Creating a Generator Command
+
+A modern generator command is a [modern spark command](cli_modern_commands.md) that:
+
+- extends `CodeIgniter\CLI\AbstractGeneratorCommand` (which itself extends `AbstractCommand`);
+- declares the usual `#[Command]` attribute **and** a `#[GeneratorCommand]` attribute.
+
+A minimal generator needs nothing else:
+
+```php
+ 'App\Commands\Generators\Views\widget.tpl.php',
+ ];
+}
+```
+
+## Generation Pipeline
+
+The default `execute()` simply returns `$this->generateClass()`, which walks through these steps:
+
+1. The `name` argument is normalized: segments are converted to PascalCase, a miscased trailing component is fixed, and the component suffix is appended when applicable. The result is qualified against the root namespace and the attribute's `directory`. A result that is not a valid class name (for example, one containing `..` segments) aborts with `EXIT_ERROR`.
+2. The target path is derived from the autoloader's mapping for the root namespace. An unknown namespace aborts with `EXIT_ERROR` and an error message.
+3. The template is rendered with the view data, placeholders are replaced, and the imports are sorted when `sortImports` is enabled.
+4. Safety checks run before writing: generating into the `CodeIgniter` namespace asks for confirmation on interactive runs (and proceeds with a warning on non-interactive ones), and an existing target file aborts with `EXIT_ERROR` unless `--force` is passed.
+
+Generators that produce more than one artifact can call `generateView()` for non-class files, and may reassign the mutable `$template` / `$templatePath` properties between generation calls.
+
+## Customization Hooks
+
+### Placeholders and View Data
+
+Most real generators need more than `{namespace}` and `{class}`. Override `getReplacements()` to add placeholder substitutions and `getTemplateData()` to pass variables into the view:
+
+```php
+addOption(new Option(
+ name: 'table',
+ description: 'Table name to use.',
+ requiresValue: true,
+ default: 'widgets',
+ ));
+ }
+
+ protected function getReplacements(string $class): array
+ {
+ // Replaces the {table} placeholder in the template.
+ return ['{table}' => (string) $this->getValidatedOption('table')];
+ }
+
+ protected function getTemplateData(string $class): array
+ {
+ // Available as a plain $sortable variable inside the template.
+ return ['sortable' => true];
+ }
+}
+```
+
+Entries returned by `getReplacements()` take precedence over the core pairs, so a generator can even override how `{namespace}` or `{class}` is derived.
+
+### Trimming Options and Forcing the Suffix
+
+The common generator options are registered through `provideGeneratorOptions()`. Override it to drop options that make no sense for your generator, and override `shouldAppendSuffix()` when suffixing is not driven by the `--suffix` flag:
+
+```php
+addNamespaceOption()->addForceOption();
+ }
+
+ protected function shouldAppendSuffix(): bool
+ {
+ // The "Widget" suffix is always appended.
+ return true;
+ }
+}
+```
+
+The base class guards every read of `--suffix` and `--force`, so omitting them is safe.
+
+### Other Hooks
+
+- `basename(string $filename): string` changes the file basename before saving. Useful for components whose file name carries a date, like migrations.
+- `getNamespace(): string` resolves the root namespace. The default returns the attribute's `namespace` or the `--namespace` option.
+- `buildPath(string $class): string` maps the qualified class to a file path through the autoloader. Override for components with special file locations, like tests.
+- `getBasePath(string $namespace): ?string` picks the directory registered for the namespace. The default takes the first autoloader entry. Override when the namespace maps to several directories and a specific one is wanted, like the `tests/` copy of `CodeIgniter`.
+- `renderTemplate(array $data = []): string` renders the resolved view.
+
+## Prompting for the Class Name
+
+`AbstractGeneratorCommand` implements `PromptsForMissingInputInterface`, so running a generator interactively without a class name prompts for it instead of failing validation. The prompt label comes from the attribute's `classNameLang` language key. Non-interactive runs (piped input, `--no-interaction`) fail fast with the usual missing-arguments error.
+
+The mechanism is available to every modern command, not just generators. See [Prompting for Missing Arguments](cli_modern_commands.md#prompting-for-missing-arguments).
+
+## Migrating From `GeneratorTrait`
+
+The base class covers everything the trait did, but the configuration moves from mutable properties and the `$params` array to the attribute and the validated input accessors.
+
+**Configuration properties**
+
+`protected $component`, `protected $template`, `protected $directory`, `protected $classNameLang`, `protected ?string $namespace`
+Move to the corresponding parameters of the `#[GeneratorCommand]` attribute.
+
+`protected ?string $templatePath`
+Stays a runtime property on the base class. Assign it only when switching templates mid-run (multi-artifact generators).
+
+`$this->setSortImports(false)`
+Becomes `sortImports: false` on the attribute.
+
+`$this->setEnabledSuffixing(false)`
+Gone. Override `provideGeneratorOptions()` to not register `--suffix`, and `shouldAppendSuffix()` to control suffixing directly. Note that a user-passed `--suffix`, which the trait silently ignored, then fails validation as an unknown option.
+
+`$this->setHasClassName(false)`
+Gone. Override `configure()` without calling `parent::configure()` and declare whatever arguments your generator actually needs. Also override `execute()`, since the default one calls `generateClass()`, which requires the `name` argument.
+
+**Input handling**
+
+`run(array $params)`
+Not used by modern commands. The default `execute()` already calls `generateClass()`. Override `execute()` only when the run involves more than one generation step.
+
+`$params[0]`
+The class name is now the declared `name` argument: `$this->getValidatedArgument('name')`.
+
+`$this->getOption('foo')` / `CLI::getOption('foo')`
+Become `$this->getValidatedOption('foo')`. Extra options must be declared in `configure()` (after calling `parent::configure()`).
+
+Class-name prompting
+Automatic. The trait prompted inside its name normalization. The base class prompts through [Prompting for Missing Arguments](cli_modern_commands.md#prompting-for-missing-arguments) before binding, so the argument is guaranteed present by the time your code runs.
+
+**Content hooks**
+
+`prepare(string $class)` override
+Gone. Return extra placeholder pairs from `getReplacements()` and view data from `getTemplateData()` instead of calling `parseTemplate()` with arrays.
+
+`basename(string $filename)` override
+Same hook name and signature on the base class.
+
+`getNamespace()` / `buildPath()` overrides
+Same hook names and signatures on the base class.
+
+A typical `GeneratorTrait` generator:
+
+```php
+ [options]';
+ protected $arguments = ['name' => 'The widget class name.'];
+ protected $options = [
+ '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".',
+ '--suffix' => 'Append the component title to the class name.',
+ '--force' => 'Force overwrite existing file.',
+ ];
+
+ public function run(array $params)
+ {
+ $this->component = 'Widget';
+ $this->directory = 'Widgets';
+ $this->template = 'widget.tpl.php';
+
+ $this->generateClass($params);
+
+ return EXIT_SUCCESS;
+ }
+}
+```
+
+…becomes, as a modern generator command:
+
+```php
+getOption('foo'); // bar
-echo $request->getOption('notthere'); // null
+// command line: php index.php users 21 profile --foo bar --option baz --option qux
+
+echo $request->getOption('foo'); // bar
+echo $request->getOption('not-there'); // null
+echo $request->getOption('option'); // qux
+```
+
+!!! note "Note"
+ Starting in v4.8.0, if the option you are trying to access is an array, this method will return the last value in the array. Use `getRawOption()` to get the full array of values for that option.
+
+### getRawOption($key)
+
+!!! success "Available from version 4.8.0"
+
+Similar to `getOption()`, but returns the full array of values for the option if it is an array:
+
+```php
+getRawOption('foo'); // bar
+echo $request->getRawOption('not-there'); // null
+var_dump($request->getRawOption('option')); // array(2) { [0]=> string(3) "baz" [1]=> string(3) "qux" }
```
### getOptionString()
diff --git a/docs/cli/index.md b/docs/cli/index.md
index 700dc0ef4cae..91c8b0536f2b 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -6,7 +6,9 @@ CodeIgniter 4 can also be used with command line programs.
- [Running Controllers via CLI](cli_controllers.md)
- [Spark Commands](spark_commands.md)
- [Creating Spark Commands](cli_commands.md)
+- [Modern Spark Commands](cli_modern_commands.md)
- [CLI Generators](cli_generators.md)
+- [Modern Generator Commands](cli_modern_generators.md)
- [CLI Library](cli_library.md)
- [CLI Signals](cli_signals.md)
- [CLIRequest Class](cli_request.md)
diff --git a/docs/cli/spark_commands.md b/docs/cli/spark_commands.md
index d3a54c745c63..1a9232b5cd0b 100644
--- a/docs/cli/spark_commands.md
+++ b/docs/cli/spark_commands.md
@@ -30,6 +30,9 @@ You may also use the `--simple` option to get a raw list of all available comman
php spark list --simple
```
+!!! note "Note"
+ [Hidden commands](cli_modern_commands.md#hidden-commands) are left out of both lists, but still run when called by name.
+
#### Showing Help
You can get help about any CLI command using the `help` command as follows:
@@ -86,6 +89,47 @@ php spark env --no-header
Your environment is currently set as development.
```
+!!! note "Note"
+ A modern command can also opt out of the header for every run. See [Commands Without the Header](cli_modern_commands.md#commands-without-the-header).
+
+#### Verbosity
+
+!!! success "Available from version 4.8.0"
+
+`--quiet` / `-q` hides everything but errors, including the header, and makes a modern command non-interactive. `--silent` hides the errors too. `--verbose` / `-v` shows more detail, `-vv` even more, and `-vvv` everything, including debug messages. A command shows the extra detail only when it writes some, and an uncaught exception shows its backtrace from `-v` on.
+
+The flags can be combined, and the quietest one wins: `--silent` over `-q`, and `-q` over any `-v`.
+
+```console
+php spark lang:find -v
+php spark migrate -q
+```
+
+Without these flags, the `SHELL_VERBOSITY` environment variable sets the level: `-2` for no output at all, `-1` for quiet, `0` for normal, and `1` to `3` for the three verbose levels. It can also be set in the **.env** file. Processes started by the command inherit the level.
+
+```console
+SHELL_VERBOSITY=-1 php spark migrate
+```
+
+!!! note "Note"
+ Legacy commands follow the level too, and can still read `-v` and `-q` as their own options.
+
+#### Correcting a Mistyped Command
+
+!!! success "Available from version 4.8.0"
+
+When the command name is not found, **spark** lists the closest matches. On an interactive run it also offers to run one of them: a single match asks for a `y`/`n` confirmation, and several matches present a menu with a "none of these" entry, picked with the arrow keys (see [select() and multiselect()](cli_library.md#select-and-multiselect)). Pressing Enter picks the highlighted entry. Non-interactive runs (`--no-interaction` / `-N`, or piped input) only print the suggestions and exit with an error, as before.
+
+```console
+php spark cache:clea
+
+Command "cache:clea" not found.
+
+Run "cache:clear" instead? [y, n]: y
+
+Cache cleared using the "file" driver.
+```
+
### Calling Commands
Commands can also be run from within your own code. This is most often done within a controller for cronjob tasks, but they can be used at any time. You do this by using the `command()` function. This function is always available.
@@ -99,3 +143,6 @@ echo command('make:migration TestMigration');
The only argument is a string containing the command to call and any parameters. This appears exactly as you would call it from the command line.
All output from the command is captured when it is not run from the command line. It is returned from the command so that you can choose to display it or not.
+
+!!! note "Note"
+ The `command()` function does not catch exceptions thrown while the command runs. [Modern commands](cli_modern_commands.md) in particular throw when their input fails validation, such as a missing required argument or an unknown option. Wrap the call in a `try`/`catch` block if you need to handle these cases.
diff --git a/docs/database/configuration.md b/docs/database/configuration.md
index 4c7ba25ac331..56fa22b0b5bd 100644
--- a/docs/database/configuration.md
+++ b/docs/database/configuration.md
@@ -187,7 +187,7 @@ class Database extends Config
'swapPre' => '',
'compress' => false,
'encrypt' => false,
- 'strictOn' => false,
+ 'strictOn' => true,
'failover' => [],
];
@@ -311,6 +311,7 @@ class Database extends Config
| **swapPre** | A default table prefix that should be swapped with `DBPrefix`. This is useful for distributed applications where you might run manually written queries, and need the prefix to still be customizable by the end user. |
| **schema** | (`Postgre` and `SQLSRV` only) The database schema. The default value varies by driver. |
| **encrypt** | (`MySQLi` and `SQLSRV` only) Whether to use an encrypted connection. See [MySQLi encrypt](#encrypt) for `MySQLi` settings. `SQLSRV` driver accepts true/false. |
+| **trustServerCertificate** | (`SQLSRV` only) true/false (boolean) - Whether to trust the server certificate without validating it against a trusted certificate authority. |
| **compress** | (`MySQLi` only) Whether to use client compression. |
| **strictOn** | (`MySQLi` only) true/false (boolean) - Whether to force "Strict Mode" connections, good for ensuring strict SQL while developing an application. |
| **port** | The database port number - Empty string `''` for default port (or dynamic port with `SQLSRV`). |
@@ -320,6 +321,7 @@ class Database extends Config
| **numberNative** | (`MySQLi` only) true/false (boolean) - Whether to enable MYSQLI_OPT_INT_AND_FLOAT_NATIVE. |
| **foundRows** | (`MySQLi` only) true/false (boolean) - Whether to enable MYSQLI_CLIENT_FOUND_ROWS. |
| **dateFormat** | The default date/time formats as PHP's [DateTime format](https://www.php.net/manual/en/datetime.format.php), with the keys listed below the table. |
+| **timezone** | (`MySQLi`, `Postgre`, and `OCI8` only) The database session timezone, with the values listed below the table. |
!!! note "Note"
CodeIgniter doesn't support dots (`.`) in the table and column names. Since v4.5.0, database names with dots are supported.
@@ -337,6 +339,14 @@ The **dateFormat** array has the following keys:
This can be used since v4.5.0, and you can get the value, e.g., `$db->dateFormat['datetime']`. Currently, the database drivers do not use these values directly, but [Model](../models/model.md#saving-dates) uses them.
+The **timezone** setting accepts the following values:
+
+- `false` - Don't set session timezone (default, backward compatible)
+- `true` - Automatically sync with `App::$appTimezone`
+- `string` - Specific timezone offset (e.g., `'+05:30'`) or named timezone (e.g., `'America/New_York'`)
+
+Named timezones are automatically converted to offsets for database compatibility. See [timezone](#timezone) for details.
+
!!! note "Note"
Depending on what database driver you are using (`MySQLi`, `Postgre`, etc.), not all values will be needed. For example, when using `SQLite3` you will not need to supply a username or password, and the database name will be the path to your database file.
@@ -369,3 +379,16 @@ The MySQLi driver accepts an array with the following options:
- `ssl_capath` - Path to a directory containing trusted CA certificates in PEM format
- `ssl_cipher` - List of *allowed* ciphers to be used for the encryption, separated by colons (`:`)
- `ssl_verify` - true/false (boolean) - Whether to verify the server certificate or not
+
+#### timezone
+
+!!! success "Available from version 4.8.0"
+
+Synchronizes the database session timezone with your application timezone to ensure consistent timestamps between model operations and database functions like `NOW()`.
+
+!!! note "Note"
+ Modern database environments usually have UTC set by default, so this option may not be needed in most cases.
+
+Accepts `false` (default, don't set), `true` (auto-sync with `App::$appTimezone`), or a timezone string (e.g., `'+05:30'` or `'America/New_York'`).
+
+Named timezones are automatically converted to offsets for compatibility.
diff --git a/docs/database/queries.md b/docs/database/queries.md
index f8f4aa0864c1..4b0413bf920d 100644
--- a/docs/database/queries.md
+++ b/docs/database/queries.md
@@ -120,6 +120,9 @@ This function determines the data type so that it can escape only string data. I
$sql = 'INSERT INTO table (title) VALUES(' . $db->escape($title) . ')';
```
+!!! success "Available from version 4.8.0"
+ `$db->escape()` accepts PHP `BackedEnum` values and escapes their backing values.
+
### 2. $db->escapeString()
This function escapes the data passed to it, regardless of type. Most of the time you'll use the above function rather than this one. Use the function like this:
@@ -174,6 +177,20 @@ SELECT * FROM some_table WHERE id IN (3,6) AND status = 'live' AND author = 'Ric
The secondary benefit of using binds is that the values are automatically escaped, producing safer queries. You don't have to remember to manually escape data - the engine does it automatically for you.
+!!! success "Available from version 4.8.0"
+ Query bindings, prepared query values, and Query Builder bound values accept PHP `BackedEnum` values. CodeIgniter uses the enum backing value before escaping or passing the value to the database driver.
+
+```php
+table('users')->where('status', \UserStatus::Active)->get();
+
+$db->query(
+ 'SELECT * FROM users WHERE status = ?',
+ [\UserStatus::Active],
+);
+```
+
### Named Bindings
Instead of using the question mark to mark the location of the bound values, you can name the bindings, allowing the keys of the values passed in to match placeholders in the query:
@@ -197,9 +214,56 @@ $db->query($sql, [
## Handling Errors
-### $db->error()
+!!! note "Note"
+ It is strongly recommended to keep `DBDebug` set to `true` (the default). This ensures all query failures surface immediately as exceptions, preventing silent data corruption. Setting it to `false` is considered legacy behavior and may be deprecated in a future version.
+
+### DBDebug Enabled (Recommended)
+
+When [DBDebug](configuration.md#description-of-values) is `true` (the default), any query failure throws a `DatabaseException` or one of its subclasses, which you can catch and handle:
+
+```php
+table('users')->insert(['email' => 'duplicate@example.com']);
+} catch (UniqueConstraintViolationException $e) {
+ // Handle duplicate key violation.
+} catch (ForeignKeyConstraintViolationException $e) {
+ // Handle missing or referenced parent row.
+} catch (ConstraintViolationException $e) {
+ // Handle another known database constraint violation.
+} catch (DatabaseException $e) {
+ // Handle another database error.
+}
+```
+
+#### Constraint Violation Exceptions
+
+!!! success "Available from version 4.8.0"
+
+Database constraint violations are reported as `ConstraintViolationException` or one of its subclasses when CodeIgniter can identify the native driver error:
-If you need to get the last error that has occurred, the `error()` method will return an array containing its code and message. Here's a quick example:
+- `UniqueConstraintViolationException`
+- `ForeignKeyConstraintViolationException`
+- `NotNullConstraintViolationException`
+- `CheckConstraintViolationException`
+
+These exceptions allow you to handle common integrity errors without inspecting raw driver-specific error codes. If a driver reports a constraint violation that CodeIgniter cannot safely classify more precisely, it throws the parent `ConstraintViolationException`.
+
+Some database engines report certain constraint failures with a generic integrity-constraint code. In those cases CodeIgniter uses the parent `ConstraintViolationException` rather than guessing the more specific subtype.
+
+### DBDebug Disabled
+
+When `DBDebug` is `false`, query and prepared query execution failures return `false` instead of throwing. Two methods are available to inspect what went wrong.
+
+#### $db->error()
+
+The `error()` method returns an array with `code` and `message` keys describing the last error. Error codes are driver-specific (an **int** for MySQLi, SQLite3, and OCI8; a SQLSTATE **string** for Postgre and SQLSRV):
```php
simpleQuery('SELECT `example_field` FROM `example_table`')) {
}
```
+#### $db->getLastException()
+
+!!! success "Available from version 4.8.0"
+
+`getLastException()` returns the typed exception that would have been thrown had `DBDebug` been `true`. This is the recommended way to distinguish between failure types (e.g., a constraint violation vs. another database error) without enabling `DBDebug`:
+
+```php
+table('users')->insert(['email' => 'duplicate@example.com']);
+
+if (! $inserted && $db->getLastException() instanceof UniqueConstraintViolationException) {
+ // Handle duplicate key violation.
+}
+```
+
+!!! note "Note"
+ `getLastException()` is reset to `null` at the start of every query or prepared query execution. Inspect it immediately after the failed operation.
+
## Prepared Queries
Most database engines support some form of prepared statements that allow you to prepare a query once, and then run that query multiple times with new sets of data. This eliminates the possibility of SQL injection since the data is passed to the database in a different format than the query itself. When you need to run the same query multiple times, it can be quite a bit faster, too. However, to use it for every query can have major performance hits, since you're calling out to the database twice as often. Since the Query Builder and Database connections already handle escaping the data for you, the safety aspect is already taken care of for you. There will be times, though, when you need the ability to optimize the query by running a prepared statement, or prepared query.
@@ -281,6 +366,9 @@ $country = 'US';
$results = $pQuery->execute($name, $email, $country);
```
+!!! success "Available from version 4.8.0"
+ Prepared query values accept PHP `BackedEnum` values and pass their backing values to the database driver.
+
For queries of type "write", it returns true or false, indicating the success or failure of the query. For queries of type "read", it returns a standard [result set](results.md).
### Other Methods
diff --git a/docs/database/query_builder.md b/docs/database/query_builder.md
index 855377f78261..990d543684b6 100644
--- a/docs/database/query_builder.md
+++ b/docs/database/query_builder.md
@@ -110,6 +110,29 @@ echo $builder->select('title, content, date')->getCompiledSelect();
The key thing to notice in the above example is that the second query did not utilize `limit(10, 20)` but the generated SQL query has `LIMIT 20, 10`. Because the parameter in the first query is set to `false`, `limit(10, 20)` remained in the second query.
+!!! note "Note"
+ **Changed in version 4.8.0.** With `$reset = true` (the default), `getCompiledSelect()`, `getCompiledInsert()`, `getCompiledUpdate()` and `getCompiledDelete()` also clear their bindings and binding counters after compilation. `getBinds()` therefore returns an empty array after these calls. Pass `false` to retain the query clauses and bindings for reuse.
+
+#### $builder->explain()
+
+!!! success "Available from version 4.8.0"
+
+Runs an execution-plan query for the current Query Builder `SELECT` query:
+
+```php
+where('status', 'pending')->explain();
+$plan = $result->getResultArray();
+```
+
+This method returns a database result object. The result columns are driver-specific because each database reports execution plans in its own format. You can read the result with `getResultArray()` or `getResultObject()`, the same as any other query result. If test mode is enabled, it returns the compiled execution-plan SQL string. If the query fails and `DBDebug` is `false`, it returns `false`.
+
+The method resets the current Query Builder state by default. If you need to keep the current Query Builder state, you can pass `false` as the first parameter.
+
+!!! note "Note"
+ This method is currently supported by MySQLi, Postgre, and SQLite3. SQLite3 uses `EXPLAIN QUERY PLAN`. SQLSRV and OCI8 are not supported by this method.
+
#### $builder->getWhere()
Identical to the `get()` method except that it permits you to add a "where" clause in the first parameter, instead of using the `$builder->where()` method:
@@ -338,6 +361,20 @@ $builder->join('user', new RawSql($sql), 'LEFT');
// Produces: LEFT JOIN "user" ON user.id = device.user_id AND ((1=1 OR 1=1) OR (1=1 OR 1=1))
```
+Since v4.8.0, the table can also be a `RawSql` instance. For example, you can join a subquery:
+
+```php
+join(
+ new RawSql('(SELECT user_id, MAX(created_at) AS latest FROM posts GROUP BY user_id) recent'),
+ 'recent.user_id = users.id',
+ 'LEFT',
+);
+```
+
!!! warning "Warning"
When you use `RawSql`, you MUST escape the values and protect the identifiers manually. Failure to do so could result in SQL injections.
@@ -355,6 +392,9 @@ This method enables you to set **WHERE** clauses using one of six methods:
!!! note "Note"
`$builder->where()` accepts an optional third parameter. If you set it to `false`, CodeIgniter will not try to protect your field or table names.
+!!! success "Available from version 4.8.0"
+ Query Builder methods that bind and escape field values or value lists, such as `where()`, `whereIn()`, `having()`, `set()`, `insert()`, and `update()`, accept PHP `BackedEnum` instances and use their backing values. Raw SQL values, such as values passed with escaping disabled, are not converted.
+
##### 1. Simple key/value method
```php
@@ -478,6 +518,107 @@ $builder->orWhere('id >', $id);
// Produces: WHERE name != 'Joe' OR id > 50
```
+#### $builder->whereColumn()
+
+!!! success "Available from version 4.8.0"
+
+Compares one column to another column. If the first parameter does not end with a supported operator, `=` is used:
+
+```php
+whereColumn('created_at', 'updated_at');
+// Produces: WHERE created_at = updated_at
+
+$builder->whereColumn('updated_at >', 'created_at');
+// Produces: WHERE updated_at > created_at
+
+$builder->whereColumn('updated_at !=', 'created_at');
+// Produces: WHERE updated_at != created_at
+```
+
+You can include a supported operator at the end of the first parameter to control the comparison. Supported operators are `=`, `!=`, `<>`, `<`, `>`, `<=`, and `>=`. Empty column names throw an `InvalidArgumentException`.
+
+Column names are protected by default, unless the `$escape` parameter is `false`.
+
+!!! warning "Warning"
+ Do not pass user-supplied data as column names. Values should use `where()` or another value-binding method instead.
+
+#### $builder->orWhereColumn()
+
+This method is identical to `whereColumn()`, except that multiple instances are joined by **OR**.
+
+#### $builder->whereBetween()
+
+!!! success "Available from version 4.8.0"
+
+Generates a **WHERE** field `BETWEEN` minimum and maximum value SQL query. `BETWEEN` includes both values:
+
+```php
+whereBetween('created_at', ['2026-01-01', '2026-01-31']);
+
+// Produces:
+// WHERE created_at BETWEEN '2026-01-01' AND '2026-01-31'
+```
+
+The range array must contain exactly two values: the lower and upper bounds. These values are bound and escaped automatically. The `$escape` parameter controls value escaping and identifier protection.
+
+!!! warning "Warning"
+ Do not pass user-supplied data as field names. If you need a more complex SQL expression, use `where()` with [RawSql](#5-rawsql) and escape values manually.
+
+#### $builder->orWhereBetween()
+
+This method is identical to `whereBetween()`, except that multiple instances are joined by **OR**.
+
+#### $builder->whereNotBetween()
+
+This method is identical to `whereBetween()`, except that it generates `NOT BETWEEN`.
+
+#### $builder->orWhereNotBetween()
+
+This method is identical to `whereNotBetween()`, except that multiple instances are joined by **OR**.
+
+#### $builder->whereExists()
+
+!!! success "Available from version 4.8.0"
+
+Generates a `WHERE EXISTS` subquery. This method accepts either a Closure or a `BaseBuilder` instance:
+
+```php
+whereExists(static function (BaseBuilder $builder) {
+ $builder->select('1', false)
+ ->from('orders')
+ ->whereColumn('orders.user_id', 'users.id');
+});
+// Produces: WHERE EXISTS (SELECT 1 FROM "orders" WHERE "orders"."user_id" = "users"."id")
+
+// With builder directly
+$subQuery = $db->table('orders')->select('1', false)->whereColumn('orders.user_id', 'users.id');
+$builder->whereNotExists($subQuery);
+```
+
+!!! warning "Warning"
+ Raw SQL strings are not accepted. If you need to write the `EXISTS` clause yourself, use `where()` with a manually escaped condition. See [5. RawSql](#5-rawsql).
+
+#### $builder->orWhereExists()
+
+This method is identical to `whereExists()`, except that multiple instances are joined by **OR**.
+
+#### $builder->whereNotExists()
+
+This method is identical to `whereExists()`, except that it generates a `WHERE NOT EXISTS` subquery.
+
+#### $builder->orWhereNotExists()
+
+This method is identical to `whereNotExists()`, except that multiple instances are joined by **OR**.
+
#### $builder->whereIn()
Generates a **WHERE field IN ('item', 'item')** SQL query joined with **AND** if appropriate:
@@ -675,9 +816,31 @@ $builder->like($rawSql, 'value', 'both');
!!! warning "Warning"
When you use `RawSql`, you MUST escape the values and protect the identifiers manually. Failure to do so could result in SQL injections.
+#### $builder->likeAny()
+
+!!! success "Available from version 4.8.0"
+
+This method generates a grouped set of **LIKE** clauses joined by **OR**. Use it when you want to search for one value across multiple fields:
+
+```php
+likeAny(['title', 'body', 'summary'], $match);
+
+/*
+ * WHERE (
+ * `title` LIKE '%match%' ESCAPE '!'
+ * OR `body` LIKE '%match%' ESCAPE '!'
+ * OR `summary` LIKE '%match%' ESCAPE '!'
+ * )
+ */
+```
+
+Unlike the associative array form of `like()`, the field list must be a non-empty list of field names. The same match value is used for every field. The field list may also contain `RawSql` instances; see [3. RawSql](#3-rawsql). Use `orLikeAny()` when the grouped search should be separated from previous conditions with **OR**.
+
#### $builder->orLike()
-This method is identical to the one above, except that multiple instances are joined by **OR**:
+This method is identical to `like()`, except that multiple instances are joined by **OR**:
```php
having('user_id', 45, false); // Produces: HAVING user_id = 45
Identical to `having()`, except that it separates multiple clauses with **OR**.
+#### $builder->havingBetween()
+
+!!! success "Available from version 4.8.0"
+
+Generates a **HAVING** field `BETWEEN` minimum and maximum value SQL query. `BETWEEN` includes both values:
+
+```php
+select('category')
+ ->selectCount('id', 'total')
+ ->groupBy('category')
+ ->havingBetween('COUNT(id)', [10, 20], false);
+```
+
+The range array must contain exactly two values: the lower and upper bounds. These values are bound and escaped automatically. The `$escape` parameter controls value escaping and identifier protection.
+
+!!! warning "Warning"
+ Do not pass user-supplied data as field names. If you need a more complex SQL expression, use `having()` with [RawSql](#5-rawsql) and escape values manually.
+
+#### $builder->orHavingBetween()
+
+This method is identical to `havingBetween()`, except that multiple instances are joined by **OR**.
+
+#### $builder->havingNotBetween()
+
+This method is identical to `havingBetween()`, except that it generates `NOT BETWEEN`.
+
+#### $builder->orHavingNotBetween()
+
+This method is identical to `havingNotBetween()`, except that multiple instances are joined by **OR**.
+
#### $builder->havingIn()
Generates a **HAVING field IN ('item', 'item')** SQL query joined with **AND** if appropriate:
@@ -1080,6 +1275,36 @@ However, this method also resets any field values that you may have passed to `s
echo $builder->countAllResults(false); // Produces an integer, like 17
```
+!!! note "Note"
+ **Changed in version 4.8.0.** With `$reset = true` (the default), bindings and binding counters are cleared after execution, so `getBinds()` returns an empty array. Pass `false` to retain the query conditions and bindings. Test mode retains bindings for the placeholders in its returned SQL.
+
+#### $builder->exists()
+
+!!! success "Available from version 4.8.0"
+
+Permits you to determine whether the current Query Builder query would return at least one row:
+
+```php
+where('status', 'pending');
+
+if ($builder->exists()) {
+ // At least one pending row exists.
+}
+```
+
+This method returns `true` when the query would return at least one row and `false` when it would not. It respects any existing `limit()` and `offset()` clauses, and resets the current Query Builder state by default. If you need to keep the current Query Builder state, you can pass `false` as the first parameter. If the existence query fails and `DBDebug` is `false`, both methods return `false`.
+
+#### $builder->doesntExist()
+
+!!! success "Available from version 4.8.0"
+
+This method is identical to `exists()`, except that it returns `true` when the query would not return any rows.
+
+!!! note "Note"
+ These methods execute the current Query Builder query to check for rows. To add an SQL `EXISTS` predicate to a query, use [$builder->whereExists()](#builder-whereexists).
+
#### $builder->countAll()
Permits you to determine the number of rows in a particular table. Example:
@@ -1092,6 +1317,157 @@ echo $builder->countAll(); // Produces an integer, like 25
As with the `countAllResults()` method, this method resets any field values that you may have passed to `select()` as well. If you need to keep them, you can pass `false` as the first parameter.
+## Pessimistic Locking
+
+### Driver Support
+
+The following table summarizes Query Builder support for pessimistic locks and lock-wait modifiers:
+
+| Query Builder methods | MySQLi | Postgre | OCI8 | SQLSRV | SQLite3 |
+|---------------------------------|--------|---------|------|----------------|---------|
+| `lockForUpdate()` | Yes | Yes | Yes | Yes | No |
+| `sharedLock()` | Yes | Yes | No | Yes | No |
+| `lockForUpdate()->nowait()` | Yes[1] | Yes | Yes | Yes | No |
+| `lockForUpdate()->skipLocked()` | Yes[2] | Yes | Yes | Approximate[3] | No |
+| `sharedLock()->nowait()` | No | Yes | No | Yes | No |
+| `sharedLock()->skipLocked()` | No | Yes | No | No | No |
+
+!!! note "Note"
+ `countAllResults()`, `exists()`, and `doesntExist()` do not apply pessimistic locks or lock-wait modifiers. Consequently, they report matching rows rather than rows currently available for locking.
+
+### Shared Lock
+
+#### $builder->sharedLock()
+
+!!! success "Available from version 4.8.0"
+
+Adds a pessimistic read lock to a `SELECT` query. This is useful when rows must be read consistently while other transactions are prevented from modifying them until the current transaction ends.
+
+```php
+transaction(static function ($db) use ($accountId): void {
+ $account = $db->table('accounts')
+ ->where('id', $accountId)
+ ->sharedLock()
+ ->get()
+ ->getRow();
+
+ // Use $account while preventing concurrent transactions from modifying it...
+});
+```
+
+Use this method inside a database transaction. Without an explicit transaction, the lock is typically released when the `SELECT` statement finishes. If the same transaction will update the selected rows, use `lockForUpdate()` instead.
+
+See [Driver Support](#driver-support) for supported drivers and lock combinations. Unsupported drivers throw a `DatabaseException`. `sharedLock()` is not supported with `union()` or `unionAll()`. Some databases restrict which query shapes can be used with row locking. When CodeIgniter can detect an unsupported combination, it throws a `DatabaseException`. See the following warnings for driver-specific behavior.
+
+!!! warning "Warning"
+ MySQLi does not support `sharedLock()` with `fromSubquery()` because an outer locking read on a derived table does not lock the underlying rows as users may expect.
+
+!!! warning "Warning"
+ Postgre does not support `sharedLock()` with `distinct()`, `groupBy()`, `having()`, or aggregate helper selections such as `selectCount()`.
+
+!!! warning "Warning"
+ SQLSRV uses SQL Server table hints instead of a trailing `FOR SHARE` clause. The hint is applied to table references in the `FROM` clause; joined tables are not hinted. Its exact lock granularity depends on SQL Server's execution plan and transaction isolation level. SQLSRV does not support `sharedLock()` without a `FROM` table or on subqueries.
+
+### Lock for Update
+
+#### $builder->lockForUpdate()
+
+!!! success "Available from version 4.8.0"
+
+Adds a pessimistic write lock to a `SELECT` query. This is useful when a row must be read and then updated safely while other transactions are prevented from modifying it first.
+
+```php
+transaction(static function ($db) use ($accountId): void {
+ $account = $db->table('accounts')
+ ->where('id', $accountId)
+ ->lockForUpdate()
+ ->get()
+ ->getRow();
+
+ // Use $account to update the locked row safely...
+});
+```
+
+Use this method inside a database transaction. Without an explicit transaction, the lock is typically released when the `SELECT` statement finishes. The exact locking behavior is determined by the database server and transaction isolation level.
+
+See [Driver Support](#driver-support) for supported drivers and lock combinations. Unsupported drivers throw a `DatabaseException`. `lockForUpdate()` is not supported with `union()` or `unionAll()`. Some databases restrict which query shapes can be used with row locking. When CodeIgniter can detect an unsupported combination, it throws a `DatabaseException`. See the following warnings for driver-specific behavior.
+
+!!! warning "Warning"
+ MySQLi does not support `lockForUpdate()` with `fromSubquery()` because an outer locking read on a derived table does not lock the underlying rows as users may expect.
+
+!!! warning "Warning"
+ Postgre does not support `lockForUpdate()` with `distinct()`, `groupBy()`, `having()`, or aggregate helper selections such as `selectCount()`.
+
+!!! warning "Warning"
+ SQLSRV uses SQL Server table hints instead of a trailing `FOR UPDATE` clause. The hint is applied to table references in the `FROM` clause; joined tables are not hinted. Its exact lock granularity depends on SQL Server's execution plan and transaction isolation level. SQLSRV does not support `lockForUpdate()` without a `FROM` table or on subqueries.
+
+!!! warning "Warning"
+ OCI8 does not support `lockForUpdate()` together with `limit()`, `offset()`, `distinct()`, `groupBy()`, `having()`, or aggregate helper selections such as `selectCount()`.
+
+### Skip Locked
+
+#### $builder->skipLocked()
+
+!!! success "Available from version 4.8.0"
+
+Adds a `SKIP LOCKED` style modifier to a pessimistic `SELECT` lock. This is useful when multiple workers claim rows from the same queue-like table and each worker should skip rows already locked by another transaction.
+
+```php
+transaction(static function ($db): void {
+ $jobs = $db->table('jobs')
+ ->where('status', 'pending')
+ ->orderBy('id', 'ASC')
+ ->limit(10)
+ ->lockForUpdate()
+ ->skipLocked()
+ ->get()
+ ->getResultArray();
+
+ // Mark or update the claimed jobs before committing the transaction...
+});
+```
+
+Use this method with `lockForUpdate()` or `sharedLock()`. Calling `skipLocked()` without a pessimistic lock throws a `DatabaseException`.
+
+`skipLocked()` can return an incomplete view of matching rows because locked rows are skipped instead of waited on. This is usually what queue workers want, but it is not suitable for general-purpose reads that must see every matching row.
+
+See [Driver Support](#driver-support) for supported drivers and lock combinations. Unsupported combinations throw a `DatabaseException`.
+
+### No Wait
+
+#### $builder->nowait()
+
+!!! success "Available from version 4.8.0"
+
+Adds a `NOWAIT` style modifier to a pessimistic `SELECT` lock. This is useful when the query should fail immediately instead of waiting for locked rows to become available.
+
+```php
+transaction(static function ($db) use ($accountId): void {
+ $account = $db->table('accounts')
+ ->where('id', $accountId)
+ ->lockForUpdate()
+ ->nowait()
+ ->get()
+ ->getRow();
+
+ // Use $account while preventing concurrent transactions from modifying it...
+});
+```
+
+Use this method with `lockForUpdate()` or `sharedLock()`. Calling `nowait()` without a pessimistic lock throws a `DatabaseException`.
+
+When a row cannot be locked immediately, the database server returns an error and CodeIgniter throws a database exception according to the current database debug and transaction settings.
+
+See [Driver Support](#driver-support) for supported drivers and lock combinations. Unsupported combinations throw a `DatabaseException`.
+
## Union queries
### Union
@@ -2042,6 +2418,9 @@ $builder->delete();
If you want to delete all data from a table, you can use the `truncate()` method, or `emptyTable()`.
+!!! note "Note"
+ **Changed in version 4.8.0.** With `$resetData = true` (the default), bindings and binding counters are cleared after execution, so `getBinds()` returns an empty array. Pass `false` as the third argument to retain the query conditions and bindings. Test mode retains bindings for the placeholders in its returned SQL.
+
#### $builder->getCompiledDelete()
This works exactly the same way as `$builder->getCompiledInsert()` except that it produces a **DELETE** SQL string instead of an **INSERT** SQL string.
@@ -2308,6 +2687,39 @@ Generates a platform-specific query string that counts all records returned by a
Generates a platform-specific query string that counts all records in the particular table.
+#### explain([$reset = true])
+
+- **Parameters**
+ - **$reset** `bool` Whether to reset values for SELECTs
+
+- **Returns**: The execution-plan result, SQL string when test mode is enabled, or `false` on failure
+
+- **Return type**: `ResultInterface|false|string`
+
+Runs an execution-plan query for the current Query Builder `SELECT` query.
+
+#### exists([$reset = true])
+
+- **Parameters**
+ - **$reset** `bool` Whether to reset values for SELECTs
+
+- **Returns**: Whether the query would return at least one row
+
+- **Return type**: `bool`
+
+Determines whether the current Query Builder query would return at least one row.
+
+#### doesntExist([$reset = true])
+
+- **Parameters**
+ - **$reset** `bool` Whether to reset values for SELECTs
+
+- **Returns**: Whether the query would not return any rows
+
+- **Return type**: `bool`
+
+Determines whether the current Query Builder query would not return any rows.
+
#### get([$limit = null[, $offset = null[, $reset = true]]])
- **Parameters**
@@ -2335,6 +2747,34 @@ Compiles and runs a `SELECT` statement based on the already called Query Builder
Same as `get()`, but also allows the WHERE to be added directly.
+#### lockForUpdate()
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+- **Return type**: `BaseBuilder`
+
+Adds a pessimistic write lock to a `SELECT` query. See [Lock for Update](#lock-for-update).
+
+#### nowait()
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+- **Return type**: `BaseBuilder`
+
+Fails immediately when selected rows cannot be locked. See [No Wait](#no-wait).
+
+#### sharedLock()
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+- **Return type**: `BaseBuilder`
+
+Adds a pessimistic read lock to a `SELECT` query. See [Shared Lock](#shared-lock).
+
+#### skipLocked()
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+- **Return type**: `BaseBuilder`
+
+Skips selected rows that cannot be locked immediately. See [Skip Locked](#skip-locked).
+
#### select([$select = '\*'[, $escape = null]])
- **Parameters**
@@ -2472,7 +2912,7 @@ Sets a query as a datasource for `insertBatch()`, `updateBatch()`, `upsertBatch(
#### join($table, $cond[, $type = ''[, $escape = null]])
- **Parameters**
- - **$table** `string` Table name to join
+ - **$table** `string|RawSql` Table name or raw SQL to join
- **$cond** `string|RawSql` The JOIN ON condition
- **$type** `string` The JOIN type
- **$escape** `bool` Whether to escape values and identifiers
@@ -2481,7 +2921,7 @@ Sets a query as a datasource for `insertBatch()`, `updateBatch()`, `upsertBatch(
- **Return type**: `BaseBuilder`
-Adds a `JOIN` clause to a query. Since v4.2.0, `RawSql` can be used as the JOIN ON condition. See also [$builder->join()](#builder-join).
+Adds a `JOIN` clause to a query. Since v4.2.0, `RawSql` can be used as the JOIN ON condition. Since v4.8.0, `RawSql` can also be used as the table. See also [$builder->join()](#builder-join).
#### where($key[, $value = null[, $escape = null]])
@@ -2509,6 +2949,128 @@ Generates the `WHERE` portion of the query. Separates multiple calls with `AND`.
Generates the `WHERE` portion of the query. Separates multiple calls with `OR`.
+#### whereColumn($first, $second[, $escape = null])
+
+- **Parameters**
+ - **$first** `string` First column name, optionally ending with a supported comparison operator
+ - **$second** `string` Second column name
+ - **$escape** `bool` Whether to protect identifiers
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+
+- **Return type**: `BaseBuilder`
+
+Generates a `WHERE` clause that compares two columns. Separates multiple calls with `AND`. If `$first` does not end with a supported operator, `=` is used as the comparison operator. Supported operators are `=`, `!=`, `<>`, `<`, `>`, `<=`, and `>=`.
+
+#### orWhereColumn($first, $second[, $escape = null])
+
+- **Parameters**
+ - **$first** `string` First column name, optionally ending with a supported comparison operator
+ - **$second** `string` Second column name
+ - **$escape** `bool` Whether to protect identifiers
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+
+- **Return type**: `BaseBuilder`
+
+Generates a `WHERE` clause that compares two columns. Separates multiple calls with `OR`. If `$first` does not end with a supported operator, `=` is used as the comparison operator. Supported operators are `=`, `!=`, `<>`, `<`, `>`, `<=`, and `>=`.
+
+#### whereBetween(?string $key = null, ?array $values = null, ?bool $escape = null)
+
+- **Parameters**
+ - **$key** `string` Name of field to examine
+ - **$values** `array` Two values defining the inclusive range
+ - **$escape** `bool` Whether to escape values and protect identifiers
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+
+- **Return type**: `BaseBuilder`
+
+Generates a `WHERE` field `BETWEEN` minimum and maximum value SQL query, joined with `AND` if appropriate.
+
+#### orWhereBetween(?string $key = null, ?array $values = null, ?bool $escape = null)
+
+- **Parameters**
+ - **$key** `string` The field to search
+ - **$values** `array` Two values defining the inclusive range
+ - **$escape** `bool` Whether to escape values and protect identifiers
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+
+- **Return type**: `BaseBuilder`
+
+Generates a `WHERE` field `BETWEEN` minimum and maximum value SQL query, joined with `OR` if appropriate.
+
+#### whereNotBetween(?string $key = null, ?array $values = null, ?bool $escape = null)
+
+- **Parameters**
+ - **$key** `string` Name of field to examine
+ - **$values** `array` Two values defining the inclusive range
+ - **$escape** `bool` Whether to escape values and protect identifiers
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+
+- **Return type**: `BaseBuilder`
+
+Generates a `WHERE` field `NOT BETWEEN` minimum and maximum value SQL query, joined with `AND` if appropriate.
+
+#### orWhereNotBetween(?string $key = null, ?array $values = null, ?bool $escape = null)
+
+- **Parameters**
+ - **$key** `string` The field to search
+ - **$values** `array` Two values defining the inclusive range
+ - **$escape** `bool` Whether to escape values and protect identifiers
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+
+- **Return type**: `BaseBuilder`
+
+Generates a `WHERE` field `NOT BETWEEN` minimum and maximum value SQL query, joined with `OR` if appropriate.
+
+#### whereExists($subquery)
+
+- **Parameters**
+ - **$subquery** `BaseBuilder|Closure` The subquery to check for matching rows
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+
+- **Return type**: `BaseBuilder`
+
+Generates a `WHERE EXISTS` subquery, joined with `AND` if appropriate.
+
+#### orWhereExists($subquery)
+
+- **Parameters**
+ - **$subquery** `BaseBuilder|Closure` The subquery to check for matching rows
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+
+- **Return type**: `BaseBuilder`
+
+Generates a `WHERE EXISTS` subquery, joined with `OR` if appropriate.
+
+#### whereNotExists($subquery)
+
+- **Parameters**
+ - **$subquery** `BaseBuilder|Closure` The subquery to check for matching rows
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+
+- **Return type**: `BaseBuilder`
+
+Generates a `WHERE NOT EXISTS` subquery, joined with `AND` if appropriate.
+
+#### orWhereNotExists($subquery)
+
+- **Parameters**
+ - **$subquery** `BaseBuilder|Closure` The subquery to check for matching rows
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+
+- **Return type**: `BaseBuilder`
+
+Generates a `WHERE NOT EXISTS` subquery, joined with `OR` if appropriate.
+
#### orWhereIn([$key = null[, $values = null[, $escape = null]]])
- **Parameters**
@@ -2611,6 +3173,21 @@ Ends a group expression.
Adds a `LIKE` clause to a query, separating multiple calls with `AND`.
+#### likeAny($fields[, $match = ''[, $side = 'both'[, $escape = null[, $insensitiveSearch = false]]]])
+
+- **Parameters**
+ - **$fields** `array` List of field names or RawSql instances
+ - **$match** `string` Text portion to match
+ - **$side** `string` Which side of the expression to put the '%' wildcard on
+ - **$escape** `bool` Whether to escape values and identifiers
+ - **$insensitiveSearch** `bool` Whether to force a case-insensitive search
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+
+- **Return type**: `BaseBuilder`
+
+Adds grouped `LIKE` clauses joined with `OR`.
+
#### orLike($field[, $match = ''[, $side = 'both'[, $escape = null[, $insensitiveSearch = false]]]])
- **Parameters**
@@ -2626,6 +3203,21 @@ Adds a `LIKE` clause to a query, separating multiple calls with `AND`.
Adds a `LIKE` clause to a query, separating multiple calls with `OR`.
+#### orLikeAny($fields[, $match = ''[, $side = 'both'[, $escape = null[, $insensitiveSearch = false]]]])
+
+- **Parameters**
+ - **$fields** `array` List of field names or RawSql instances
+ - **$match** `string` Text portion to match
+ - **$side** `string` Which side of the expression to put the '%' wildcard on
+ - **$escape** `bool` Whether to escape values and identifiers
+ - **$insensitiveSearch** `bool` Whether to force a case-insensitive search
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+
+- **Return type**: `BaseBuilder`
+
+Adds grouped `LIKE` clauses joined with `OR`, separating the group from previous conditions with `OR`.
+
#### notLike($field[, $match = ''[, $side = 'both'[, $escape = null[, $insensitiveSearch = false]]]])
- **Parameters**
@@ -2682,6 +3274,58 @@ Adds a `HAVING` clause to a query, separating multiple calls with `AND`.
Adds a `HAVING` clause to a query, separating multiple calls with `OR`.
+#### havingBetween(?string $key = null, ?array $values = null, ?bool $escape = null)
+
+- **Parameters**
+ - **$key** `string` Name of field to examine
+ - **$values** `array` Two values defining the inclusive range
+ - **$escape** `bool` Whether to escape values and protect identifiers
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+
+- **Return type**: `BaseBuilder`
+
+Generates a `HAVING` field `BETWEEN` minimum and maximum value SQL query, joined with `AND` if appropriate.
+
+#### orHavingBetween(?string $key = null, ?array $values = null, ?bool $escape = null)
+
+- **Parameters**
+ - **$key** `string` The field to search
+ - **$values** `array` Two values defining the inclusive range
+ - **$escape** `bool` Whether to escape values and protect identifiers
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+
+- **Return type**: `BaseBuilder`
+
+Generates a `HAVING` field `BETWEEN` minimum and maximum value SQL query, joined with `OR` if appropriate.
+
+#### havingNotBetween(?string $key = null, ?array $values = null, ?bool $escape = null)
+
+- **Parameters**
+ - **$key** `string` Name of field to examine
+ - **$values** `array` Two values defining the inclusive range
+ - **$escape** `bool` Whether to escape values and protect identifiers
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+
+- **Return type**: `BaseBuilder`
+
+Generates a `HAVING` field `NOT BETWEEN` minimum and maximum value SQL query, joined with `AND` if appropriate.
+
+#### orHavingNotBetween(?string $key = null, ?array $values = null, ?bool $escape = null)
+
+- **Parameters**
+ - **$key** `string` The field to search
+ - **$values** `array` Two values defining the inclusive range
+ - **$escape** `bool` Whether to escape values and protect identifiers
+
+- **Returns**: `BaseBuilder` instance (method chaining)
+
+- **Return type**: `BaseBuilder`
+
+Generates a `HAVING` field `NOT BETWEEN` minimum and maximum value SQL query, joined with `OR` if appropriate.
+
#### orHavingIn([$key = null[, $values = null[, $escape = null]]])
- **Parameters**
@@ -3127,6 +3771,20 @@ Compiles and executes a batch `DELETE` query.
Increments the value of a field by the specified amount. If the field is not a numeric field, like a `VARCHAR`, it will likely be replaced with `$value`.
+#### incrementMany($columns[, $value = 1])
+
+!!! success "Available from version 4.8.0"
+
+- **Parameters**
+ - **$columns** `array` A list of columns or array of column => value pairs to increment.
+ - **$value** `int` The amount to increment in the columns, if `$columns` is a list of columns.
+
+- **Returns**: `true` on success, `false` on failure
+
+- **Return type**: `bool`
+
+Increments the value of multiple fields by the specified amounts. If a field is not a numeric field, like a `VARCHAR`, it will likely be replaced with the amount specified for that field.
+
#### decrement($column[, $value = 1])
- **Parameters**
@@ -3135,6 +3793,20 @@ Increments the value of a field by the specified amount. If the field is not a n
Decrements the value of a field by the specified amount. If the field is not a numeric field, like a `VARCHAR`, it will likely be replaced with `$value`.
+#### decrementMany($columns[, $value = 1])
+
+!!! success "Available from version 4.8.0"
+
+- **Parameters**
+ - **$columns** `array` A list of columns or array of column => value pairs to decrement.
+ - **$value** `int` The amount to decrement in the columns, if `$columns` is a list of columns.
+
+- **Returns**: `true` on success, `false` on failure
+
+- **Return type**: `bool`
+
+Decrements the value of multiple fields by the specified amounts. If a field is not a numeric field, like a `VARCHAR`, it will likely be replaced with the amount specified for that field.
+
#### truncate()
- **Returns**: `true` on success, `false` on failure, string on test mode
@@ -3195,3 +3867,9 @@ Compiles an `UPDATE` statement and returns it as a string.
- **Return type**: `string`
Compiles a `DELETE` statement and returns it as a string.
+
+[1] `NOWAIT` requires MySQL 8.0.1+ or MariaDB 10.3.0+ with InnoDB tables. CodeIgniter does not check the server version or storage engine before compiling the clause.
+
+[2] `SKIP LOCKED` requires MySQL 8.0.1+ or MariaDB 10.6.0+ with InnoDB tables. CodeIgniter does not check the server version or storage engine before compiling the clause.
+
+[3] SQLSRV approximates `skipLocked()` using SQL Server's `READCOMMITTEDLOCK` and `READPAST` table hints. It can still wait on locks that `READPAST` does not skip, such as page-level locks.
diff --git a/docs/database/transactions.md b/docs/database/transactions.md
index 13abfe8b5fd9..a843875e0891 100644
--- a/docs/database/transactions.md
+++ b/docs/database/transactions.md
@@ -29,6 +29,120 @@ $this->db->transComplete();
You can run as many queries as you want between the `transStart()`/`transComplete()` methods, and they will all be committed or rolled back based on the success or failure of any given query.
+## Running Transactions with a Closure
+
+!!! success "Available from version 4.8.0"
+
+You may also run a transaction with the `transaction()` method. It starts a transaction, runs the callback, commits when the callback completes successfully, and rolls back if the callback throws an exception:
+
+```php
+db->transaction(static function ($db) use ($order, $id) {
+ $db->table('orders')->insert($order);
+ $orderId = $db->insertID();
+
+ $db->table('stock')->where('id', $id)->decrement('qty');
+
+ $db->afterCommit(static function (): void {
+ // Dispatch a job or send a notification after commit.
+ });
+
+ return $orderId;
+});
+```
+
+The callback receives the current database connection as its only argument. If the transaction commits successfully, `transaction()` returns the callback return value. If the transaction cannot begin, or if a query failure marks the transaction as failed without throwing an exception, `transaction()` rolls back and returns `false`.
+
+If transactions are disabled, `transaction()` does not start a transaction. It runs the callback and returns the callback result.
+
+If the callback throws an exception, `transaction()` rolls back and rethrows the original exception unless retry attempts remain, as described below.
+
+If an `afterRollback()` callback throws while `transaction()` is rolling back, that callback exception bubbles to the caller instead of the normal `false` return value or the original callback exception.
+
+Callbacks registered with `afterCommit()` or `afterRollback()` inside the transaction callback follow the same rules as other transaction callbacks: they run only after the outermost transaction commits or rolls back.
+
+### Scoped Transaction Options
+
+The `transaction()` method can temporarily override existing transaction options for the duration of the helper call:
+
+```php
+db->transaction(
+ static function ($db) use ($order) {
+ $db->table('orders')->insert($order);
+
+ return $db->insertID();
+ },
+ transException: true,
+ resetTransStatus: true,
+);
+```
+
+Set `transException` to temporarily enable or disable the same transaction exception mode configured by `transException()`. The previous mode is restored after `transaction()` finishes, even if the callback throws an exception. If `transaction()` is called inside an active transaction, the temporary mode applies while the nested callback runs, then the previous mode is restored for the outer transaction. When `transException` is set to `true` in a nested transaction and a query fails, CodeIgniter's existing transaction exception handling rolls back the outer transaction as well.
+
+Set `resetTransStatus` to reset the transaction status before the helper starts an outermost transaction. This is equivalent to calling `resetTransStatus()` before the transaction, and is useful when strict mode has marked the connection as failed from an earlier transaction.
+
+`resetTransStatus` only applies when `transaction()` starts the outermost transaction. If `transaction()` is called inside an active transaction, it does not reset the outer transaction status.
+
+The `transException` option follows CodeIgniter's existing transaction exception behavior, including the current `DBDebug` setting.
+
+### Retrying Transactions
+
+You may pass `attempts` when you want CodeIgniter to retry the whole transaction after retryable database concurrency failures, such as deadlocks or serialization failures. Retries happen when a `RetryableTransactionException` occurs while the callback is running, including from query or prepared query execution:
+
+```php
+db->transaction(static function ($db) use ($order) {
+ $db->table('orders')->insert($order);
+
+ return $db->insertID();
+}, attempts: 3);
+```
+
+`attempts` is the total number of times to try the transaction, including the first run, and must be greater than or equal to `1`.
+
+Keep these rules in mind when using retry attempts:
+
+- The callback may run more than once, and retries run immediately without delay or backoff.
+- Retries only apply when `transaction()` starts the outermost transaction. If `transaction()` is called inside an active transaction, the callback runs once using the existing nested transaction behavior.
+- Only `RetryableTransactionException` failures are retried. Other exceptions are rolled back and rethrown without another attempt.
+- Retry attempts do not retry failures that are reported only while the transaction is committing.
+- If an `afterRollback()` callback throws while a failed attempt is rolling back, that callback exception bubbles to the caller and no further attempts are made.
+
+Avoid non-transactional side effects inside callbacks that may be retried. For side effects such as queued jobs, emails, cache invalidation, events, or external API calls, register them with `afterCommit()` so they run only after the final successful commit.
+
+`afterRollback()` callbacks may run for failed retry attempts even when a later attempt commits successfully, so use them only for cleanup that is safe to run per rolled-back attempt.
+
+## Classifying Retryable Transaction Failures
+
+!!! success "Available from version 4.8.0"
+
+Some database engines report transaction failures that may succeed when the entire transaction is attempted again, such as deadlocks or serialization failures. When a driver classifies a query or prepared query execution failure as one of these retryable transaction failures, CodeIgniter throws `RetryableTransactionException` so you can decide how your application should respond:
+
+```php
+transException(true)->transaction(static function ($db) {
+ $db->table('orders')->insert($order);
+
+ return $db->insertID();
+ });
+} catch (RetryableTransactionException $e) {
+ // Retry the whole transaction according to your application's policy.
+ throw $e;
+}
+```
+
+`RetryableTransactionException` is the classifier used by `transaction()` retry attempts. If you are not using `transaction()` attempts, catch this exception and retry the whole transaction according to your application's policy. Avoid non-transactional side effects inside transaction bodies that may be retried. For side effects such as queued jobs, emails, cache invalidation, or external API calls, register them with `afterCommit()` so they run only after the transaction commits.
+
+When `DBDebug` is `false` and a failed query or prepared query returns `false` instead of throwing, inspect `getLastException()` immediately after the failed operation. It will contain the `RetryableTransactionException` instance when the driver classifies the failure as retryable.
+
## Strict Mode
By default, CodeIgniter runs all transactions in Strict Mode.
@@ -108,6 +222,121 @@ try {
If a query error occurs, all the queries will be rolled back, and a `DatabaseException` will be thrown.
+## Running Code after Commit or Rollback
+
+!!! success "Available from version 4.8.0"
+
+You may register callbacks to run only after the outermost transaction has successfully committed by using the `afterCommit()` method, or after the outermost transaction has rolled back by using the `afterRollback()` method:
+
+```php
+db->transStart();
+$this->db->query('AN SQL QUERY...');
+
+$this->db->afterCommit(static function (): void {
+ // Dispatch a queued job or run another side effect after commit.
+});
+
+$this->db->afterRollback(static function (): void {
+ // Run cleanup that should only happen after rollback.
+});
+
+$this->db->transComplete();
+```
+
+Callbacks registered during an active transaction are delayed until the outermost transaction commits or rolls back.
+
+If the transaction commits, `afterCommit()` callbacks run and `afterRollback()` callbacks are discarded. If the transaction rolls back, `afterRollback()` callbacks run and `afterCommit()` callbacks are discarded. If no transaction is active, `afterCommit()` callbacks run immediately, while `afterRollback()` callbacks are not run.
+
+For example:
+
+```php
+db->afterCommit(static function (): void {
+ // Runs immediately because no transaction has started yet.
+});
+
+$this->db->transStart();
+$this->db->query('AN SQL QUERY...');
+$this->db->transComplete();
+```
+
+!!! note "Note"
+ When `afterCommit()` is called outside an active transaction, it runs immediately. This includes calls from Model `beforeInsert` or `beforeUpdate` events when the calling code has not already started a transaction, so the callback may run before the Model's insert or update query is executed.
+
+This is useful for side effects that should only happen after committed data is visible, such as dispatching a queued job or sending a notification, and for cleanup that should only happen after a real rollback.
+
+### Deferring Side Effects
+
+Code that changes external state should usually not run in the middle of a transaction. If the transaction rolls back after the side effect has already run, the application may send a notification for data that was never saved, invalidate a cache for a write that did not persist, or start background work that cannot find the committed row it expects.
+
+Register those side effects with `afterCommit()` instead:
+
+```php
+db->transaction(static function ($db) use ($order): int {
+ $db->table('orders')->insert($order);
+ $insertedOrderId = $db->insertID();
+
+ $db->afterCommit(static function () use ($insertedOrderId): void {
+ service('cache')->delete('orders_list');
+ Events::trigger('order_created', $insertedOrderId);
+
+ // Dispatch a queued job or send a notification here.
+ // The new order is committed and visible to other database connections.
+ });
+
+ return $insertedOrderId;
+});
+
+if ($orderId === false) {
+ // Handle the transaction failure.
+}
+```
+
+Use `afterRollback()` for cleanup that should happen only when the transaction does not commit, such as removing a temporary file created before the database write:
+
+```php
+db->transaction(static function ($db) use ($temporaryPath, $record): void {
+ $db->afterRollback(static function () use ($temporaryPath): void {
+ if (is_file($temporaryPath)) {
+ unlink($temporaryPath);
+ }
+ });
+
+ $db->table('documents')->insert($record);
+});
+```
+
+Model callbacks such as `afterInsert` and `afterUpdate` run after the Model query has executed, but not necessarily after the surrounding transaction has committed. If a Model callback needs to run a side effect only after commit, it should register that work with the database connection's `afterCommit()` method while an active transaction is already open.
+
+Callbacks run after the database transaction has already committed or rolled back. If a callback throws an exception, that exception bubbles to the caller, but the transaction outcome is not changed.
+
+!!! warning "Warning"
+ When multiple callbacks are registered for the same transaction outcome, they run in registration order. If one callback throws an exception, the subsequent callbacks are not run.
+
+Rollback callbacks also run when CodeIgniter automatically rolls back an active transaction while handling a transaction failure or cleaning up an unfinished transaction.
+
+## Checking Transaction State
+
+!!! success "Available from version 4.8.0"
+
+You may use `inTransaction()` to check whether the connection is currently inside an active CodeIgniter-managed transaction.
+
+It returns `false` when no CodeIgniter-managed transaction is active, including when transactions are disabled.
+
+This is useful for services or libraries that need to adapt their behavior when they are called from inside an existing transaction.
+
+!!! note "Note"
+ `inTransaction()` reflects transactions started through CodeIgniter's transaction methods. If you start or end transactions through raw SQL or driver-specific APIs, CodeIgniter will not be aware of those transactions.
+
## Disabling Transactions
Transactions are enabled by default. If you would like to disable transactions, you can do so using `$this->db->transOff()`:
diff --git a/docs/dbmgmt/migration.md b/docs/dbmgmt/migration.md
index 43389268b35b..4f1e544c4ba7 100644
--- a/docs/dbmgmt/migration.md
+++ b/docs/dbmgmt/migration.md
@@ -160,8 +160,8 @@ php spark migrate
You can use `migrate` with the following options:
-- `-g` - to specify a database group. If specified, only migrations for the specified database group will be run. If not specified, all migrations will be run.
-- `-n` - to choose a namespace, otherwise the `App` namespace will be used.
+- `--group` (`-g`) - to specify a database group. If specified, only migrations for the specified database group will be run. If not specified, all migrations will be run.
+- `--namespace` (`-n`) - to choose a namespace, otherwise the `App` namespace will be used.
- `--all` - to migrate all namespaces to the latest migration.
This example will migrate the `Acme\Blog` namespace with any new migrations on the test database group:
@@ -190,8 +190,8 @@ php spark migrate:rollback
You can use `migrate:rollback` with the following options:
-- `-b` - to choose a batch: natural numbers specify the batch.
-- `-f` - to bypass the confirmation question, which is only asked in a production environment.
+- `--batch` (`-b`) - to choose a batch: natural numbers specify the batch.
+- `--force` (`-f`) - to bypass the confirmation question, which is only asked in a production environment.
### migrate:refresh
@@ -203,10 +203,10 @@ php spark migrate:refresh
You can use `migrate:refresh` with the following options:
-- `-g` - to specify a database group. If specified, only migrations for the specified database group will be run. If not specified, all migrations will be run.
-- `-n` - to choose a namespace, otherwise the `App` namespace will be used.
+- `--group` (`-g`) - to specify a database group. If specified, only migrations for the specified database group will be run. If not specified, all migrations will be run.
+- `--namespace` (`-n`) - to choose a namespace, otherwise the `App` namespace will be used.
- `--all` - to refresh all namespaces.
-- `-f` - to bypass the confirmation question, which is only asked in a production environment.
+- `--force` (`-f`) - to bypass the confirmation question, which is only asked in a production environment.
### migrate:status
@@ -228,7 +228,7 @@ php spark migrate:status
You can use `migrate:status` with the following options:
-- `-g` - to specify a database group. If specified, only migrations for the specified database group will be checked. If not specified, all migrations will be checked.
+- `--group` (`-g`) - to specify a database group. If specified, only migrations for the specified database group will be checked. If not specified, all migrations will be checked.
### make:migration
@@ -240,14 +240,14 @@ php spark make:migration [options]
You can use `make:migration` with the following options:
-- `--namespace` - Set root namespace. Default: `APP_NAMESPACE`.
-- `--suffix` - Append the component title to the class name.
+- `--namespace` (`-n`) - Set root namespace. Default: `APP_NAMESPACE`.
+- `--suffix` (`-s`) - Append the component title to the class name.
The following options are also available to generate the migration file for database sessions:
- `--session` - Generates the migration file for database sessions.
-- `--table` - Table name to use for database sessions. Default: `ci_sessions`.
-- `--dbgroup` - Database group to use for database sessions. Default: `default`.
+- `--table` (`-t`) - Table name to use for database sessions. Default: `ci_sessions`.
+- `--dbgroup` (`-g`) - Database group to use for database sessions. Default: `default`.
## Migration Preferences
diff --git a/docs/extending/core_classes.md b/docs/extending/core_classes.md
index d82634e921f0..e7b045c0f0cb 100644
--- a/docs/extending/core_classes.md
+++ b/docs/extending/core_classes.md
@@ -41,6 +41,7 @@ The following core system classes may be initialized during execution, depending
- `CodeIgniter\HTTP\SiteURIFactory`
- `CodeIgniter\HTTP\URI`
- `CodeIgniter\HTTP\UserAgent` (if launched over HTTP)
+- `CodeIgniter\Lock\LockManager`
- `CodeIgniter\Log\Logger`
- `CodeIgniter\Log\Handlers\BaseHandler`
- `CodeIgniter\Log\Handlers\FileHandler`
diff --git a/docs/general/caching.md b/docs/general/caching.md
index 58142e51d04a..6df847821d38 100644
--- a/docs/general/caching.md
+++ b/docs/general/caching.md
@@ -56,7 +56,7 @@ Valid options are:
Using an empty array `[]` may cache temporary error pages (404, 500, etc.). For production applications, consider restricting this to `[200]` to avoid caching errors that should be temporary. For example, a cached 404 page would remain cached even after the resource is created, until the cache expires.
!!! note "Note"
- Regardless of this setting, `DownloadResponse` and `RedirectResponse` instances are never cached by the `PageCache` filter.
+ Regardless of this setting, `DownloadResponse`, `RedirectResponse`, and HTTP `QUERY` responses are never cached by the `PageCache` filter.
## Enabling Caching
diff --git a/docs/general/configuration.md b/docs/general/configuration.md
index 368491a0262a..e85c5e136166 100644
--- a/docs/general/configuration.md
+++ b/docs/general/configuration.md
@@ -311,6 +311,174 @@ class Registrar
Registrar methods must always return an array, with keys corresponding to the properties of the target config file. Existing values are merged, and Registrar properties have overwrite priority.
+By default this merge is **shallow** (top-level only). Arrays are combined with `array_merge()`, so a nested array under a top-level key *replaces* the existing nested array rather than merging into it. In the following example the `key2` subtree from the config is replaced entirely, dropping `val2` and `val3`:
+
+```php
+ 'val1',
+ 'key2' => ['val2' => 'subVal2', 'val3' => 'subVal3'],
+ ];
+}
+
+// Modules/MyModule/Config/Registrar.php - plain array (shallow merge)
+
+namespace MyModule\Config;
+
+class Registrar
+{
+ public static function Example(): array
+ {
+ return ['arrayNested' => ['key2' => ['val4' => 'subVal4']]];
+ }
+}
+
+// Result - the nested array under "key2" is replaced wholesale, so
+// "val2" and "val3" are silently dropped:
+//
+// 'arrayNested' => [
+// 'key1' => 'val1',
+// 'key2' => ['val4' => 'subVal4'],
+// ]
+```
+
+#### Controlling how values are merged
+
+!!! success "Available from version 4.8.0"
+
+When a registrar needs finer control than the shallow default, it can return a `CodeIgniter\Config\Merge` directive as the value of a property. Merge directives are explicit instructions to either keep merging into a value, replace it, or add items to a list.
+
+The two directives that control whole values are:
+
+- `Merge::replace($value)` - discard the existing value and use `$value`. Accepts **any type** (scalar, `null`, or array - e.g. `['a', 'b']` becomes `['c']`, or `Merge::replace('redis')`).
+- `Merge::byKey($value)` - deep-merge by key: **string keys recurse, integer keys append, and scalar leaves are replaced**. The name is deliberately not `recursive` to avoid confusion with PHP's `array_merge_recursive()`, which collects scalar values into arrays instead of replacing them.
+
+Use `Merge::byKey()` when you want to navigate into nested configuration and preserve sibling keys:
+
+```php
+ Merge::byKey([
+ 'key2' => ['val4' => 'subVal4'],
+ ]),
+ ];
+ }
+}
+
+// Result - the sibling keys are preserved:
+//
+// 'arrayNested' => [
+// 'key1' => 'val1',
+// 'key2' => ['val2' => 'subVal2', 'val3' => 'subVal3', 'val4' => 'subVal4'],
+// ]
+```
+
+!!! important "Important"
+ Inside `Merge::byKey()`, plain arrays are still merged by key. Use `Merge::replace()` when you want to stop merging at that key and overwrite the value. For example, `'after' => []` leaves an existing `after` list unchanged, while `'after' => Merge::replace([])` clears it.
+
+The following registrar adds a filter to `globals['before']` while hard-resetting `globals['after']`, leaving any other `globals` keys untouched:
+
+```php
+ Merge::byKey([
+ 'before' => Merge::append(['blogFilter']), // add to the existing list
+ 'after' => Merge::replace([]), // hard reset, plain [] would keep merging
+ ]),
+ ];
+ }
+
+ // Scalar replace also works at the property root:
+ public static function Cache(): array
+ {
+ return ['handler' => Merge::replace('redis')];
+ }
+}
+```
+
+The *list* strategies add items to a list and control where they land. They are useful where order matters, such as the filter lists in `Config\Filters`:
+
+- `Merge::append($value)` - add items to the **end** of the list (e.g. `['a', 'b']` becomes `['a', 'b', 'c']`).
+- `Merge::prepend($value)` - add items to the **front** of the list (e.g. `['a', 'b']` becomes `['c', 'a', 'b']`).
+- `Merge::before($anchor, $value)` - insert items immediately **before** the first element equal to `$anchor`.
+- `Merge::after($anchor, $value)` - insert items immediately **after** the first element equal to `$anchor`.
+
+All four de-duplicate, so the directives never *introduce* a duplicate value: a value already in the list is not added again, and duplicate payload values are collapsed (e.g. `Merge::append(['x', 'x'])` adds `x` once). Duplicates that already exist in the current list are left as-is. They differ only in how they treat a value that is *already present*:
+
+- `append()` / `prepend()` leave an already-present value **where it is** (they only add values that are missing).
+- `before()` / `after()` **move** an already-present value to the anchor position - but only when the anchor is in the list. If the anchor is missing they fall back to `append()` / `prepend()` respectively, and do **not** relocate a value that is already present.
+
+The anchor is matched strictly against the **direct elements** of the list; the list strategies act on a single list level and never recurse. To reach a list that is nested under other keys (such as `globals['before']`), navigate to it with `Merge::byKey()` and place the list directive at that key:
+
+```php
+ Merge::byKey([
+ // Run "auth" immediately after "csrf" in the before-list.
+ 'before' => Merge::after('csrf', ['auth']),
+ // Run "honeypot" first in the after-list.
+ 'after' => Merge::prepend(['honeypot']),
+ ]),
+ ];
+ }
+}
+
+// Given a base of:
+// 'before' => ['csrf', 'invalidchars'],
+// 'after' => ['toolbar'],
+// the result is:
+// 'before' => ['csrf', 'auth', 'invalidchars'],
+// 'after' => ['honeypot', 'toolbar'],
+```
+
+!!! important "Important"
+ Merge directives are interpreted only when used as the **value of a config property** returned by a registrar, and recursively **inside** `Merge::byKey()`. The payloads of the `replace()`, `append()`, `prepend()`, `before()`, and `after()` strategies are taken literally and are **not** scanned for nested directives - for nested control, wrap the property in `Merge::byKey()` and place the directives at its keys.
+
+!!! note "Note"
+ Merge directives sharpen a *single* registrar's intent; they do not add an explicit priority mechanism between registrars. Registrars are still applied in discovery order, and an item's final position follows from that order *plus* the strategy: with `append()` an earlier registrar's items sit ahead of a later one's, while with `prepend()`, `before()`, and `after()` a later registrar's items land **nearer the front or the anchor** than an earlier one's. For example, `after('csrf', ['a'])` followed by `after('csrf', ['b'])` from a second registrar yields `['csrf', 'b', 'a']`. As always, **.env** values take priority over registrars.
+
### Explicit Registrars
Explicit Registrars can only change the Config class properties in which they are registered.
@@ -420,7 +588,7 @@ Config\App#6 (12) (
public 'CSPEnabled' -> boolean false
)
-Config Caching: Disabled
+Config caching: disabled
```
You can see if Config Caching is enabled or not.
diff --git a/docs/general/context.md b/docs/general/context.md
new file mode 100644
index 000000000000..888d4c41fc30
--- /dev/null
+++ b/docs/general/context.md
@@ -0,0 +1,525 @@
+# Context
+
+!!! success "Available from version 4.8.0"
+
+## What is it?
+
+The Context class provides a simple, convenient way to store and retrieve user-defined data throughout a single request. It functions as a key-value store that can hold any data you need to access across different parts of your application during the request lifecycle.
+
+The Context class is particularly useful for:
+
+- Storing request-specific metadata (user IDs, request IDs, correlation IDs)
+- Passing data between filters, controllers, and other components
+- Adding contextual information to your logs automatically
+- Storing sensitive data that should not appear in logs
+
+## Accessing Context Class
+
+You can access the Context service anywhere in your application using the `service()` function or the `context()` helper:
+
+```php
+set('user_id', 123);
+```
+
+### Setting Multiple Values
+
+You can also set multiple values at once by passing an array:
+
+```php
+set([
+ 'user_id' => 123,
+ 'username' => 'john_doe',
+ 'request_id' => 'req_abc123',
+ 'correlation_id' => 'corr_xyz789',
+]);
+```
+
+The `set()` method returns the Context instance, allowing you to chain multiple calls:
+
+```php
+set('user_id', 123)
+ ->set('username', 'john_doe')
+ ->set('request_id', 'req_abc123');
+```
+
+## Getting Context Data
+
+### Retrieving a Single Value
+
+Use the `get()` method to retrieve a value by its key:
+
+```php
+get('user_id');
+// $userId = 123
+```
+
+You can provide a default value as the second parameter, which will be returned if the key doesn't exist:
+
+```php
+get('user_role', 'guest');
+// If 'user_role' doesn't exist, $role will be 'guest'
+```
+
+### Retrieving All Data
+
+To get all stored context data:
+
+```php
+getAll();
+// Returns: ['user_id' => 123, 'username' => 'john_doe', ...]
+```
+
+### Retrieving Specific Keys
+
+You can retrieve only specific keys using `getOnly()`:
+
+```php
+getOnly(['user_id', 'username']);
+// Returns: ['user_id' => 123, 'username' => 'john_doe']
+
+// You can also pass a single key as a string
+$userId = $context->getOnly('user_id');
+// Returns: ['user_id' => 123]
+```
+
+If you need all data except specific keys, use `getExcept()`:
+
+```php
+getExcept(['password', 'api_key']);
+// Returns all data except 'password' and 'api_key'
+
+// You can also pass a single key as a string
+$data = $context->getExcept('password');
+// Returns all data except 'password'
+```
+
+## Checking for Data
+
+You can check if a key exists in the context:
+
+```php
+has('user_id')) {
+ // Do something with user_id
+}
+```
+
+## Removing Context Data
+
+### Removing a Single Value
+
+You can remove data from the context using the `remove()` method:
+
+```php
+remove('user_id');
+```
+
+### Removing Multiple Values
+
+To remove multiple keys at once, pass an array:
+
+```php
+remove(['user_id', 'username', 'request_id']);
+```
+
+### Clearing All Data
+
+To remove all context data:
+
+```php
+clear();
+```
+
+## Hidden Context Data
+
+The Context class provides a separate storage area for sensitive data that should not be included in logs. This is useful for storing API keys, passwords, tokens, or other sensitive information that you need to access during the request but don't want to expose in log files.
+
+### Setting Hidden Data
+
+Use the `setHidden()` method to store sensitive data:
+
+```php
+setHidden('api_key', 'sk_live_abc123xyz789');
+```
+
+You can also set multiple hidden values at once:
+
+```php
+setHidden([
+ 'api_key' => 'sk_live_abc123xyz789',
+ 'api_secret' => 'secret_key_here',
+ 'db_password' => 'database_password',
+]);
+```
+
+### Getting Hidden Data
+
+Retrieve hidden data using `getHidden()`:
+
+```php
+getHidden('api_key');
+// $apiKey = 'sk_live_abc123xyz789'
+```
+
+The same methods available for regular data also work with hidden data:
+
+```php
+getHidden('api_key', 'default_key');
+
+// Get only specific hidden keys
+$credentials = $context->getOnlyHidden(['api_key', 'api_secret']);
+
+// Get all hidden data except specific keys
+$data = $context->getExceptHidden(['db_password']);
+
+// Get all hidden data
+$allHidden = $context->getAllHidden();
+```
+
+### Checking Hidden Data
+
+Check if a hidden key exists:
+
+```php
+hasHidden('api_key')) {
+ // API key is set
+}
+```
+
+### Removing Hidden Data
+
+Remove hidden data using `removeHidden()`:
+
+```php
+removeHidden('api_key');
+
+// Remove multiple hidden values
+$context->removeHidden(['api_key', 'api_secret']);
+```
+
+### Clearing Hidden Data
+
+To clear all hidden data without affecting regular context data:
+
+```php
+clearHidden();
+```
+
+To clear both regular and hidden data:
+
+```php
+clearAll();
+```
+
+!!! important "Important"
+ Regular data and hidden data are stored separately. A key can exist in both regular and hidden storage with different values. Use `get()` for regular data and `getHidden()` for hidden data.
+
+## Integration with Logging
+
+The Context class integrates seamlessly with CodeIgniter's logging system. When enabled, context data is automatically appended to log messages, providing additional information for debugging and monitoring.
+
+### Enabling Global Context Logging
+
+To enable automatic logging of context data, set the `$logGlobalContext` property to `true` in your **app/Config/Logger.php** file:
+
+```php
+set('user_id', 123);
+$context->set('transaction_id', 'txn_12345');
+
+log_message('error', 'Payment processing failed');
+```
+
+This would produce a log entry like:
+
+```text
+ERROR - 2026-02-18 --> Payment processing failed {"user_id":123,"transaction_id":"txn_12345"}
+```
+
+!!! note "Note"
+ Hidden data set with `setHidden()` is **never** included in logs, even when `$logGlobalContext` is enabled. This ensures sensitive information like API keys or tokens remain secure.
+
+## Important Notes
+
+- Context data persists only for the duration of a single request. It is not shared between requests.
+- The Context service is shared by default, meaning there is one instance per request.
+- Hidden data is never included in logs, regardless of the logging configuration.
+- Regular context data and hidden context data are stored separately and can have overlapping keys.
+- Context is cleared automatically at the end of each request.
+- In testing environments, remember to clear context data between tests using `clearAll()` to ensure test isolation.
+
+## Class Reference
+
+## CodeIgniter\Context
+
+### Context
+
+#### set($key[, $value = null])
+
+- **Parameters**
+ - **$key** `array|string` The key or an array of key-value pairs
+ - **$value** `mixed` The value to store (ignored if `$key` is an array)
+
+- **Returns**: Context instance for method chaining
+
+- **Return type**: `Context`
+
+Sets one or more key-value pairs in the context.
+
+#### setHidden($key[, $value = null])
+
+- **Parameters**
+ - **$key** `array|string` The key or an array of key-value pairs
+ - **$value** `mixed` The value to store (ignored if `$key` is an array)
+
+- **Returns**: Context instance for method chaining
+
+- **Return type**: `Context`
+
+Sets one or more key-value pairs in the hidden context.
+
+#### get($key[, $default = null])
+
+- **Parameters**
+ - **$key** `string` The key to retrieve
+ - **$default** `mixed` Default value if key doesn't exist
+
+- **Returns**: The value or default
+
+- **Return type**: `mixed`
+
+Gets a value from the context.
+
+#### getHidden($key[, $default = null])
+
+- **Parameters**
+ - **$key** `string` The key to retrieve
+ - **$default** `mixed` Default value if key doesn't exist
+
+- **Returns**: The value or default
+
+- **Return type**: `mixed`
+
+Gets a value from the hidden context.
+
+#### getOnly($keys)
+
+- **Parameters**
+ - **$keys** `array|string` Key or array of keys to retrieve
+
+- **Returns**: Array of key-value pairs
+
+- **Return type**: `array`
+
+Gets only the specified keys from the context.
+
+#### getOnlyHidden($keys)
+
+- **Parameters**
+ - **$keys** `array|string` Key or array of keys to retrieve
+
+- **Returns**: Array of key-value pairs
+
+- **Return type**: `array`
+
+Gets only the specified keys from the hidden context.
+
+#### getExcept($keys)
+
+- **Parameters**
+ - **$keys** `array|string` Key or array of keys to exclude
+
+- **Returns**: Array of key-value pairs
+
+- **Return type**: `array`
+
+Gets all context data except the specified keys.
+
+#### getExceptHidden($keys)
+
+- **Parameters**
+ - **$keys** `array|string` Key or array of keys to exclude
+
+- **Returns**: Array of key-value pairs
+
+- **Return type**: `array`
+
+Gets all hidden context data except the specified keys.
+
+#### getAll()
+
+- **Returns**: All context data
+- **Return type**: `array`
+
+Gets all data from the context.
+
+#### getAllHidden()
+
+- **Returns**: All hidden context data
+- **Return type**: `array`
+
+Gets all data from the hidden context.
+
+#### has($key)
+
+- **Parameters**
+ - **$key** `string` The key to check
+
+- **Returns**: True if key exists, false otherwise
+
+- **Return type**: `bool`
+
+Checks if a key exists in the context.
+
+#### hasHidden($key)
+
+- **Parameters**
+ - **$key** `string` The key to check
+
+- **Returns**: True if key exists, false otherwise
+
+- **Return type**: `bool`
+
+Checks if a key exists in the hidden context.
+
+#### missing($key)
+
+- **Parameters**
+ - **$key** `string` The key to check
+
+- **Returns**: True if key doesn't exist, false otherwise
+
+- **Return type**: `bool`
+
+Checks if a key doesn't exist in the context. Opposite of `has()`.
+
+#### missingHidden($key)
+
+- **Parameters**
+ - **$key** `string` The key to check
+
+- **Returns**: True if key doesn't exist, false otherwise
+
+- **Return type**: `bool`
+
+Checks if a key doesn't exist in the hidden context. Opposite of `hasHidden()`.
+
+#### remove($key)
+
+- **Parameters**
+ - **$key** `array|string` The key or array of keys to remove
+
+- **Returns**: Context instance for method chaining
+
+- **Return type**: `Context`
+
+Removes one or more keys from the context.
+
+#### removeHidden($key)
+
+- **Parameters**
+ - **$key** `array|string` The key or array of keys to remove
+
+- **Returns**: Context instance for method chaining
+
+- **Return type**: `Context`
+
+Removes one or more keys from the hidden context.
+
+#### clear()
+
+- **Returns**: Context instance for method chaining
+- **Return type**: `Context`
+
+Clears all data from the context (does not affect hidden data).
+
+#### clearHidden()
+
+- **Returns**: Context instance for method chaining
+- **Return type**: `Context`
+
+Clears all data from the hidden context (does not affect regular data).
+
+#### clearAll()
+
+- **Returns**: Context instance for method chaining
+- **Return type**: `Context`
+
+Clears all data from both the context and hidden context.
diff --git a/docs/general/environments.md b/docs/general/environments.md
index 060a1e3bc72b..cda733c9fbf5 100644
--- a/docs/general/environments.md
+++ b/docs/general/environments.md
@@ -94,6 +94,34 @@ You can also check the current environment with the `spark env` command:
php spark env
```
+### The `environment` service
+
+!!! success "Available from version 4.8.0"
+
+As an alternative to reading the `ENVIRONMENT` constant directly, CodeIgniter provides the `environment` service, backed by the `CodeIgniter\EnvironmentDetector` class. Because it is a shared service, it can be mocked in tests (via `Services::injectMock()`) to exercise environment-specific branches without having to redefine the `ENVIRONMENT` constant.
+
+```php
+get();
+
+// Check against the three built-in environments.
+$environment->isProduction();
+$environment->isDevelopment();
+$environment->isTesting();
+
+// Match any one of several environments (useful for custom names).
+$environment->is('production', 'staging');
+```
+
+!!! note "Note"
+ The `environment` service is primarily intended for testing environment-specific code paths. Mocking it only affects code that resolves and uses the service itself. It does not modify the `ENVIRONMENT` constant. Code that still reads `ENVIRONMENT` directly keeps its current behavior.
+
+Passing a value to the constructor overrides the detected environment; passing `null` (the default) falls back to the `ENVIRONMENT` constant. An empty or whitespace-only string throws `CodeIgniter\Exceptions\InvalidArgumentException`.
+
## Effects on Default Framework Behavior
There are some places in the CodeIgniter system where the `ENVIRONMENT` constant is used. This section describes how default framework behavior is affected.
diff --git a/docs/general/errors.md b/docs/general/errors.md
index 1c5306e5ec07..674728675023 100644
--- a/docs/general/errors.md
+++ b/docs/general/errors.md
@@ -258,6 +258,26 @@ throw new \CodeIgniter\Database\Exceptions\DatabaseException();
This provides an exit code of 8.
+### UniqueConstraintViolationException
+
+!!! success "Available from version 4.8.0"
+
+`UniqueConstraintViolationException` extends `DatabaseException` and is thrown when a query fails due to a duplicate key or unique constraint violation. It is supported by all database drivers.
+
+```php
+table('users')->insert(['email' => 'duplicate@example.com']);
+} catch (UniqueConstraintViolationException $e) {
+ // Handle duplicate key violation
+}
+```
+
+See [Constraint Violation Exceptions](../database/queries.md#constraint-violation-exceptions) for full usage details.
+
### RedirectException
!!! note "Note"
diff --git a/docs/general/index.md b/docs/general/index.md
index c8dcf2161e43..7ea101d0e5a9 100644
--- a/docs/general/index.md
+++ b/docs/general/index.md
@@ -7,6 +7,7 @@
- [Logging Information](logging.md)
- [Error Handling](errors.md)
- [Web Page Caching](caching.md)
+- [Context](context.md)
- [AJAX Requests](ajax.md)
- [Code Modules](modules.md)
- [Managing your Applications](managing_apps.md)
diff --git a/docs/general/logging.md b/docs/general/logging.md
index e1a2fd71519f..f7fe2e7763a4 100644
--- a/docs/general/logging.md
+++ b/docs/general/logging.md
@@ -98,6 +98,46 @@ class Logger extends BaseConfig
}
```
+### Stopping the Handler Chain on Failure
+
+!!! success "Available from version 4.8.0"
+
+Handlers are executed in the order they are defined in the `$handlers` property. When a handler's `handle()` method returns `false`, the handlers defined after it are not executed.
+
+By default, the **File Handler** and the **Errorlog Handler** return `false` when they fail to write the log, for example when the log directory is not writable. This means that a failing handler prevents the handlers after it from logging the message.
+
+If you want the remaining handlers to run even when a handler fails to write the log, set its `stopChainOnFailure` option to `false`:
+
+```php
+ [
+ 'handles' => ['critical', 'alert', 'emergency', 'debug', 'error', 'info', 'notice', 'warning'],
+ 'stopChainOnFailure' => false,
+ ],
+ // Errorlog Handler
+ 'CodeIgniter\Log\Handlers\ErrorlogHandler' => [
+ 'handles' => ['critical', 'alert', 'emergency', 'debug', 'error', 'info', 'notice', 'warning'],
+ ],
+ ];
+
+ // ...
+}
+```
+
+In the example above, the **Errorlog Handler** still logs the message when the **File Handler** cannot write to the log file.
+
+!!! note "Note"
+ This option only affects failed writes in the **File Handler** and the **Errorlog Handler**. A custom handler can still return `false` from `handle()` to stop the remaining handlers.
+
## Modifying the Message with Context
You will often want to modify the details of your message based on the context of the event being logged. You might need to log a user ID, an IP address, the current POST variables, etc. You can do this by using placeholders in your message. Each placeholder must be wrapped in curly braces. In the third parameter, you must provide an array of placeholder names (without the braces) and their values. These will be inserted into the message string:
@@ -138,6 +178,118 @@ Several core placeholders exist that will be automatically expanded for you base
| {line} | The line in {file} where the logger was called |
| {env:foo} | The value of 'foo' in $_ENV |
+### Global Context Logging
+
+!!! success "Available from version 4.8.0"
+
+You can automatically append context data to all log messages by enabling the `$logGlobalContext` property in **app/Config/Logger.php**:
+
+```php
+set('user_id', 123);
+$context->set('transaction_id', 'txn_12345');
+
+log_message('error', 'Payment processing failed');
+```
+
+When enabled, all regular context data (set via the [Context class](context.md#context)) is automatically appended to every log message as a JSON string:
+
+```php
+set('user_id', 123);
+$context->set('transaction_id', 'txn_12345');
+
+log_message('error', 'Payment processing failed');
+```
+
+This would produce a log entry like:
+
+```text
+ERROR - 2026-02-18 --> Payment processing failed {"user_id":123,"transaction_id":"txn_12345"}
+```
+
+!!! note "Note"
+ Hidden data set with `setHidden()` is **never** included in log output, even when `$logGlobalContext` is enabled. This protects sensitive information such as API keys and tokens from appearing in log files.
+
+See [Context](context.md#context) for full documentation on storing and managing context data.
+
+### Per-Call Context Logging
+
+!!! success "Available from version 4.8.0"
+
+By default, context values passed to `log_message()` are only used for placeholder interpolation and are not stored anywhere. You can enable structured context logging by setting `$logContext = true` in **app/Config/Logger.php**:
+
+```php
+ 'ord_999', // interpolated into the message, stripped from context by default
+ 'user_id' => 42, // not in message, kept and passed to handlers
+]);
+
+// Handlers receive context: ['user_id' => 42]
+```
+
+**Throwable normalization**
+
+Per PSR-3, a `Throwable` instance must be passed under the `exception` key to be handled specially. When found there, it is automatically normalized into a meaningful array instead of being serialized as an empty object:
+
+```php
+ $e]);
+}
+
+// Handlers receive context:
+// [
+// 'exception' => [
+// 'class' => 'RuntimeException',
+// 'message' => 'Something went wrong',
+// 'code' => 0,
+// 'file' => 'app/Controllers/Payment.php',
+// 'line' => 42,
+// ]
+// ]
+```
+
+The normalized array contains `class`, `message`, `code`, `file`, and `line`. To also include the full stack trace, set `$logContextTrace = true`.
+
+!!! note "Note"
+ `$logContext` and `$logGlobalContext` are independent. You can enable either or both. When both are enabled, per-call context and global context are merged before being passed to handlers.
+
## Using Third-Party Loggers
You can use any other logger that you might like as long as it implements `Psr\Log\LoggerInterface` and is [PSR-3](https://www.php-fig.org/psr/psr-3/) compatible. This means that you can easily drop in any PSR-3 compatible logger, or create your own.
diff --git a/docs/general/modules.md b/docs/general/modules.md
index 6befbab8b7ab..238eab0671eb 100644
--- a/docs/general/modules.md
+++ b/docs/general/modules.md
@@ -282,7 +282,7 @@ $config = new \Acme\Blog\Config\Blog();
Config files are automatically discovered when you use the always-available `config()` function and pass a short classname to it.
!!! note "Note"
- We don't recommend you use the same short classname in modules. Modules that need to override or add to known configurations in **app/Config/** should use [Implicit Registrars](configuration.md#registrars).
+ We don't recommend you use the same short classname in modules. Modules that need to override or add to known configurations in **app/Config/** should use [Implicit Registrars](configuration.md#registrars). To contribute to *nested* configuration (filters, permission matrices, and so on) without clobbering existing values, use the [merge directives](configuration.md#controlling-how-values-are-merged).
!!! note "Note"
Prior to v4.4.0, `config()` found the file in **app/Config/** when there was a class with the same shortname, even if you specified a fully qualified class name like `config(\Acme\Blog\Config\Blog::class)`. This behavior was fixed in v4.4.0, and it now returns the specified instance.
diff --git a/docs/helpers/array_helper.md b/docs/helpers/array_helper.md
index f76403b89ba6..cd97d28bfafc 100644
--- a/docs/helpers/array_helper.md
+++ b/docs/helpers/array_helper.md
@@ -14,17 +14,22 @@ helper('array');
The following functions are available:
-#### dot_array_search(string $search, array $values)
+!!! note "Note"
+ Since v4.8.0, the dot-path helpers can read values from arrays or objects, including `Entity` objects. This applies to `dot_array_search()`, `dot_array_has()`, `dot_array_only()`, `dot_array_except()`, and `array_group_by()`.
+
+ `dot_array_set()` and `dot_array_unset()` still modify arrays only. `dot_array_only()` and `dot_array_except()` always return arrays. See their descriptions for how object values are handled.
+
+#### dot_array_search(string $search, array|object $values)
- **Parameters**
- **$search** `string` The dot-notation string describing how to search the array
- - **$values** `array` The array to search
+ - **$values** `array|object` The array or object to search
- **Returns**: The value found within the array, or null
- **Return type**: `mixed`
-This method allows you to use dot-notation to search through an array for a specific key, and allows the use of the `*` wildcard. Given the following array:
+This method allows you to use dot-notation to search through arrays and objects for a specific key or property, and allows the use of the `*` wildcard. Given the following array:
```php
$data = [
@@ -77,6 +82,254 @@ $fooBar = dot_array_search('foo\.bar.baz', $data);
!!! note "Note"
Prior to v4.2.0, `dot_array_search('foo.bar.baz', ['foo' => ['bar' => 23]])` returned `23` due to a bug. v4.2.0 and later return `null`.
+#### dot_array_has(string $search, array|object $values): bool
+
+- **Parameters**
+ - **$search** `string` The dot-notation string describing how to search the array
+ - **$values** `array|object` The array or object to check
+
+- **Returns**: `true` if the key exists, otherwise `false`
+
+- **Return type**: `bool`
+
+!!! success "Available from version 4.8.0"
+
+Checks if an array key exists using dot syntax. This method supports wildcard `*` in the same way as `dot_array_search()`.
+
+```php
+$data = [
+ 'users' => [
+ ['id' => 1, 'name' => 'Jane'],
+ ['id' => 2, 'name' => 'John'],
+ ],
+];
+
+// Returns: true (all matched users have an "id" key)
+$hasIds = dot_array_has('users.*.id', $data);
+
+// If any user is missing "id", this would return false.
+
+// Returns: false
+$hasEmails = dot_array_has('users.*.email', $data);
+```
+
+#### dot_array_set(array &$array, string $search, mixed $value): void
+
+- **Parameters**
+ - **$array** `array` The array to modify (passed by reference)
+ - **$search** `string` The dot-notation string describing where to set the value
+ - **$value** `mixed` The value to set
+
+- **Return type**: `void`
+
+!!! success "Available from version 4.8.0"
+
+Sets an array value using dot syntax. Missing path segments are created automatically. Wildcard `*` is supported with the same rule as `dot_array_has()`: you must specify a key right after `*`.
+
+```php
+$data = [];
+
+dot_array_set($data, 'user.profile.id', 123);
+dot_array_set($data, 'user.profile.name', 'John');
+
+$users = [
+ ['name' => 'Jane'],
+ ['name' => 'John'],
+];
+
+dot_array_set($users, '*.active', true);
+
+/*
+$data is now:
+[
+ 'user' => [
+ 'profile' => [
+ 'id' => 123,
+ 'name' => 'John',
+ ],
+ ],
+]
+*/
+
+/*
+$users is now:
+[
+ ['name' => 'Jane', 'active' => true],
+ ['name' => 'John', 'active' => true],
+]
+*/
+```
+
+#### dot_array_unset(array &$array, string $search): bool
+
+- **Parameters**
+ - **$array** `array` The array to modify (passed by reference)
+ - **$search** `string` The dot-notation string describing which key to remove
+
+- **Returns**: `true` if a key was removed, otherwise `false`
+
+- **Return type**: `bool`
+
+!!! success "Available from version 4.8.0"
+
+Removes array values using dot syntax. Wildcard `*` is supported. You can target specific keys like `users.*.id` or clear all keys under a path with `user.*`.
+
+```php
+$data = [
+ 'user' => [
+ 'profile' => [
+ 'id' => 123,
+ 'name' => 'John',
+ ],
+ ],
+];
+
+// Returns: true
+$removed = dot_array_unset($data, 'user.profile.id');
+
+// Returns: false (path does not exist)
+$removedAgain = dot_array_unset($data, 'user.profile.id');
+
+$users = [
+ ['id' => 1, 'name' => 'Jane'],
+ ['id' => 2, 'name' => 'John'],
+];
+
+// Returns: true (removes "id" from all user rows)
+$removedIds = dot_array_unset($users, '*.id');
+
+// Returns: true (clears all keys under "user")
+$clearedUser = dot_array_unset($data, 'user.*');
+```
+
+#### dot_array_only(array|object $array, array|string $indexes): array
+
+- **Parameters**
+ - **$array** `array|object` The source array or object
+ - **$indexes** `array|string` One key or a list of keys using dot notation
+
+- **Returns**: Nested array containing only the requested keys
+
+- **Return type**: `array`
+
+!!! success "Available from version 4.8.0"
+
+Gets only the specified keys using dot syntax while preserving nested structure.
+
+Wildcard `*` is supported. Unlike `dot_array_set()` and `dot_array_unset()`, this method also allows wildcard at the end (for example `user.*`).
+
+The result is always an array. If a selected value is an object, that object is kept as the value. If you select a value inside an object, the returned path is built with arrays:
+
+```php
+$user = (object) ['id' => 123, 'name' => 'John'];
+
+// Selecting the object itself keeps the object as the value.
+$whole = dot_array_only(['user' => $user], 'user');
+// ['user' => $user]
+
+// Selecting a value inside the object builds the path with arrays.
+$partial = dot_array_only(['user' => $user], 'user.id');
+// ['user' => ['id' => 123]]
+```
+
+```php
+$data = [
+ 'user' => [
+ 'id' => 123,
+ 'name' => 'John',
+ 'email' => 'john@example.com',
+ ],
+ 'meta' => [
+ 'request_id' => 'abc',
+ ],
+];
+
+$only = dot_array_only($data, ['user.id', 'meta.request_id']);
+/*
+$only:
+[
+ 'user' => ['id' => 123],
+ 'meta' => ['request_id' => 'abc'],
+]
+*/
+
+$userOnly = dot_array_only($data, 'user.*');
+/*
+$userOnly:
+[
+ 'user' => [
+ 'id' => 123,
+ 'name' => 'John',
+ 'email' => 'john@example.com',
+ ],
+]
+*/
+```
+
+#### dot_array_except(array|object $array, array|string $indexes): array
+
+- **Parameters**
+ - **$array** `array|object` The source array or object
+ - **$indexes** `array|string` One key or a list of keys using dot notation
+
+- **Returns**: Nested array with the specified keys removed
+
+- **Return type**: `array`
+
+!!! success "Available from version 4.8.0"
+
+Gets all keys except the specified ones using dot syntax.
+
+Wildcard `*` is supported. Unlike `dot_array_set()` and `dot_array_unset()`, this method also allows wildcard at the end (for example `user.*`).
+
+The result is always an array. Object values that are not changed are kept as they are. If a key is removed from inside an object, that part of the result is returned as an array:
+
+```php
+$user = (object) ['id' => 123, 'name' => 'John'];
+
+// An untouched object is kept as it is.
+$untouched = dot_array_except(['user' => $user], 'meta.id');
+// ['user' => $user]
+
+// Removing a key from inside an object returns that part as an array.
+$partial = dot_array_except(['user' => $user], 'user.id');
+// ['user' => ['name' => 'John']]
+```
+
+```php
+$data = [
+ 'user' => [
+ 'id' => 123,
+ 'name' => 'John',
+ 'email' => 'john@example.com',
+ ],
+ 'meta' => [
+ 'request_id' => 'abc',
+ ],
+];
+
+$except = dot_array_except($data, ['user.email', 'meta.request_id']);
+/*
+$except:
+[
+ 'user' => [
+ 'id' => 123,
+ 'name' => 'John',
+ ],
+ 'meta' => [],
+]
+*/
+
+$clearUser = dot_array_except($data, 'user.*');
+/*
+$clearUser:
+[
+ 'user' => [],
+ 'meta' => ['request_id' => 'abc'],
+]
+*/
+```
+
#### array_deep_search($key, array $array)
- **Parameters**
@@ -87,7 +340,7 @@ $fooBar = dot_array_search('foo\.bar.baz', $data);
- **Return type**: `mixed`
-Returns the value of an element with a key value in an array of uncertain depth.
+Returns the value of an element with a key value in an array of uncertain depth. Only nested arrays are searched, and object values are not traversed.
#### array_sort_by_multiple_keys(array &$array, array $sortColumns)
@@ -188,7 +441,7 @@ In the same way, the method can also handle an array of objects. In the example
- **Returns**: The flattened array
-This function flattens a multidimensional array to a single key-value array by using dots as separators for the keys.
+This function flattens a multidimensional array to a single key-value array by using dots as separators for the keys. The source may be any `iterable`. Only nested arrays are flattened, and object values are kept as-is, as leaf values.
```php
$arrayToFlatten = [
@@ -244,7 +497,7 @@ $flattened = array_flatten_with_dots($arrayToFlatten, 'foo_');
- **Returns**: An array grouped by index values
-This function allows you to group data rows together by index values. The depth of the returned array equals the number of indexes passed as a parameter.
+This function allows you to group data rows together by index values. The depth of the returned array equals the number of indexes passed as a parameter. Data rows may be arrays or objects, and dot syntax can read nested array keys or object properties.
The example shows some data (e.g., loaded from an API) with nested arrays.
diff --git a/docs/incoming/controllers.md b/docs/incoming/controllers.md
index 4e5404a75c71..213a7d650fd6 100644
--- a/docs/incoming/controllers.md
+++ b/docs/incoming/controllers.md
@@ -188,7 +188,7 @@ class UserController extends BaseController
```
!!! warning "Warning"
- When you use the `validate()` method, you should use the [getValidated()](../libraries/validation.md#getting-validated-data) method to get the validated data, because the `validate()` method uses the [Validation::withRequest()](../libraries/validation.md#withrequest) method internally, and it validates data from [$request->getJSON()](incomingrequest.md#getting-json-data) or [$request->getRawInput()](incomingrequest.md#retrieving-raw-data-put-patch-delete) or [$request->getVar()](incomingrequest.md#getting-data), and an attacker could change what data is validated.
+ When you use the `validate()` method, you should use the [getValidated()](../libraries/validation.md#getting-validated-data) method to get the validated data, because the `validate()` method uses the [Validation::withRequest()](../libraries/validation.md#withrequest) method internally, and it validates data from [$request->getJSON()](incomingrequest.md#getting-json-data) or [$request->getRawInput()](incomingrequest.md#retrieving-raw-data-put-patch-delete-query) or [$request->getVar()](incomingrequest.md#getting-data), and an attacker could change what data is validated.
!!! note "Note"
The [$this->validator->getValidated()](../libraries/validation.md#getting-validated-data) method can be used since v4.4.0.
diff --git a/docs/incoming/filters.md b/docs/incoming/filters.md
index db6033208191..76921f6803d9 100644
--- a/docs/incoming/filters.md
+++ b/docs/incoming/filters.md
@@ -431,6 +431,7 @@ The filters bundled with CodeIgniter4 are:
- `forcehttps` => [ForceHTTPS](#forcehttps)
- `pagecache` => [PageCache](../general/caching.md)
- `performance` => [PerformanceMetrics](#performancemetrics)
+- `requestid` => [Request ID](#request-id)
!!! note "Note"
The filters are executed in the order defined in the config file. However, if enabled, `DebugToolbar` is always executed last because it should be able to capture everything that happens in the other filters.
@@ -495,3 +496,44 @@ class Filters extends BaseConfig
```
If you want to know about secure headers, see [OWASP Secure Headers Project](https://owasp.org/www-project-secure-headers/).
+
+### Request ID
+
+!!! success "Available from version 4.8.0"
+
+This filter adds a request ID to each request in the context of the application. This can be useful for debugging and logging purposes, as it allows you to trace a specific request through the application.
+
+Framework-generated IDs are 32-character hexadecimal strings and are unique for practical purposes. However, valid incoming IDs are reused. It is added to the request's context and can be accessed via the `request_id` key.
+
+To enable this filter, simply add/uncomment the `requestid` alias in the `$required['before']` and `$required['after']` array in **app/Config/Filters.php**:
+
+```php
+ [
+ 'requestid', // Request ID for each request
+ 'forcehttps', // Force Global Secure Requests
+ 'pagecache', // Web Page Caching
+ ],
+ 'after' => [
+ 'pagecache', // Web Page Caching
+ 'requestid', // Request ID for each request
+ 'performance', // Performance Metrics
+ 'toolbar', // Debug Toolbar
+ ],
+ ];
+
+ // ...
+}
+```
+
+!!! note "Note"
+ If the incoming request has a header named `X-Request-ID`, the value of that header will be used as the request ID instead of generating a new one or checking for uniqueness. The framework does basic validation to ensure that the incoming request ID is a non-empty string, has at most 64 characters, and contains only valid characters (alphanumeric, dot, underscore, colon, and hyphen). If the validation fails, a new request ID will be generated as normal. This allows you to pass in your own request ID if you have one available, such as from a client or a load balancer.
diff --git a/docs/incoming/form_requests.md b/docs/incoming/form_requests.md
new file mode 100644
index 000000000000..4236535e8824
--- /dev/null
+++ b/docs/incoming/form_requests.md
@@ -0,0 +1,458 @@
+# Form Requests
+
+!!! success "Available from version 4.8.0"
+
+A **FormRequest** is a dedicated class that encapsulates the validation rules, custom error messages, and authorization logic for a single HTTP request. Instead of writing validation code inside every controller method, you define it once in a FormRequest class and type-hint it in the controller method signature - the framework resolves, authorizes, and validates the request automatically before your method body runs.
+
+## Creating a Form Request
+
+Use the `make:request` Spark command to generate a new FormRequest class:
+
+```
+php spark make:request StorePostRequest
+```
+
+This creates **app/Requests/StorePostRequest.php**. Fill in the `rules()` method with the same field/rule pairs you would normally pass to [Running Validation](../libraries/validation.md#running-validation):
+
+```php
+namespace App\Requests;
+
+use CodeIgniter\HTTP\FormRequest;
+
+class StorePostRequest extends FormRequest
+{
+ public function rules(): array
+ {
+ return [
+ 'title' => 'required|min_length[3]|max_length[255]',
+ 'body' => 'required',
+ ];
+ }
+}
+```
+
+## Using a Form Request in a Controller
+
+Type-hint the FormRequest class as a parameter of your controller method. The framework instantiates it, runs authorization and validation, and passes the resolved object to your method. If validation fails, the default behavior redirects back with errors for web requests or returns a 422 JSON response for JSON request bodies or requests that prefer `application/json` - the method body is never reached.
+
+```php
+namespace App\Controllers;
+
+use App\Requests\StorePostRequest;
+
+class Posts extends BaseController
+{
+ public function store(StorePostRequest $request): string
+ {
+ // $request->getValidated() returns only the fields declared in rules().
+ $data = $request->getValidated();
+
+ // save to database
+
+ return redirect()->to('/posts');
+ }
+}
+```
+
+## Accessing Validated Data
+
+`getValidated()` returns an array containing only the fields that were declared in `rules()`. Fields submitted by the client that are not covered by a rule are silently discarded, protecting against mass-assignment.
+
+```php
+$data = $request->getValidated();
+// ['title' => 'My post title', 'body' => 'Body text']
+```
+
+Use `getValidatedInput()` when you want to read a single validated field or check whether a validated key exists, including keys whose value is `null`. The input object supports dot-array syntax for nested validated data:
+
+```php
+$input = $request->getValidatedInput();
+
+$title = $input->get('title');
+$slug = $input->get('post.meta.slug', 'draft');
+
+if ($input->has('note')) {
+ // 'note' was validated, even if its value is null
+}
+```
+
+### Typed Validated Input
+
+`getValidatedInput()` returns the same validated data as a typed input object. This complements `getValidated()` by making common controller values easier to read after validation has succeeded.
+
+After the FormRequest has been validated, read the successful values in the controller:
+
+```php
+use App\Enums\PostStatus;
+
+$input = $request->getValidatedInput();
+
+$page = $input->integer('page', 1);
+$rating = $input->float('rating', 0.0);
+$active = $input->boolean('active', false);
+$publishedAt = $input->date('published_at', 'Y-m-d');
+$status = $input->enum('status', PostStatus::class, PostStatus::DRAFT);
+```
+
+These typed methods do not replace validation rules. They only make accepted values easier to consume in the controller. See [Typed Validated Input](../libraries/validation.md#typed-validated-input) for the full behavior of the typed input methods.
+
+### Accessing Other Request Data
+
+For anything not covered by `getValidated()` - uploaded files, request headers, the client IP address, raw input, and so on - use `$this->request` as usual. It is the same [IncomingRequest](incomingrequest.md) instance that the FormRequest uses internally:
+
+```php
+namespace App\Controllers;
+
+use App\Requests\StorePostRequest;
+
+class Posts extends BaseController
+{
+ public function store(StorePostRequest $request): string
+ {
+ $data = $request->getValidated();
+ $files = $this->request->getFiles();
+
+ // ...
+ }
+}
+```
+
+## Route Parameters Alongside a Form Request
+
+Required scalar route parameters come first, then the FormRequest, then any optional scalar parameters. The framework matches URI segments to scalar parameters positionally and injects the FormRequest wherever the type hint appears. Parameters declared with PHP's `...` syntax capture all remaining URI segments. Optional scalar parameters after the FormRequest get their default values when no URI segment is left to fill them.
+
+```php
+namespace App\Controllers;
+
+use App\Requests\UpdatePostRequest;
+
+class Posts extends BaseController
+{
+ // Route parameters come first; FormRequest follows.
+ public function update(int $id, UpdatePostRequest $request): string
+ {
+ $data = $request->getValidated();
+
+ // update post $id with $data
+
+ return redirect()->to('/posts/' . $id);
+ }
+}
+```
+
+## Closure Routes
+
+FormRequest injection works identically in closure routes and follows the same signature shape: route parameters first, then the FormRequest, then optional scalar parameters. The framework resolves it the same way it does for controller methods.
+
+```php
+use App\Requests\StorePostRequest;
+use App\Requests\UpdatePostRequest;
+
+// In app/Config/Routes.php
+
+// FormRequest only - no route parameters.
+$routes->post('posts', static function (StorePostRequest $request): string {
+ $data = $request->getValidated();
+
+ // save to database
+
+ return redirect()->to('/posts');
+});
+
+// Route parameter before a FormRequest - same convention as controller methods.
+$routes->post('posts/(:num)', static function (int $id, UpdatePostRequest $request): string {
+ $data = $request->getValidated();
+
+ // update post $id with $data
+
+ return redirect()->to('/posts/' . $id);
+});
+```
+
+## `_remap()` Methods
+
+Automatic FormRequest injection is **not** supported for controller methods that use `_remap()`. Because `_remap()` has a fixed signature `($method, ...$params)`, there is no typed position for the framework to inject a FormRequest into.
+
+Instantiate the FormRequest manually inside `_remap()` and call `resolveRequest()` yourself. The method returns `null` on success or a `ResponseInterface` when authorization or validation fails:
+
+```php
+namespace App\Controllers;
+
+use App\Requests\UpdatePostRequest;
+use CodeIgniter\HTTP\ResponseInterface;
+
+class Posts extends BaseController
+{
+ public function _remap(string $method, string ...$params): mixed
+ {
+ // FormRequest is not injected automatically into _remap().
+ // Instantiate it manually and call resolveRequest() yourself.
+ if ($method === 'update') {
+ $request = new UpdatePostRequest();
+ $response = $request->resolveRequest();
+
+ if ($response instanceof ResponseInterface) {
+ // Authorization or validation failed - send the response.
+ return $response;
+ }
+
+ return $this->update($request, ...$params);
+ }
+
+ return $this->{$method}(...$params);
+ }
+
+ private function update(UpdatePostRequest $request, string $id): string
+ {
+ $data = $request->getValidated();
+
+ // update post $id with $data
+
+ return redirect()->to('/posts/' . $id);
+ }
+}
+```
+
+## Custom Error Messages
+
+Override `messages()` to return field-specific error messages. The format is identical to the `$errors` argument of [Saving Sets of Validation Rules to the Config File](../libraries/validation.md#saving-sets-of-validation-rules-to-the-config-file):
+
+```php
+namespace App\Requests;
+
+use CodeIgniter\HTTP\FormRequest;
+
+class StorePostRequest extends FormRequest
+{
+ public function rules(): array
+ {
+ return [
+ 'title' => 'required|min_length[3]|max_length[255]',
+ 'body' => 'required',
+ ];
+ }
+
+ public function messages(): array
+ {
+ return [
+ 'title' => [
+ 'required' => 'Post title cannot be empty.',
+ 'min_length' => 'Post title must be at least {param} characters long.',
+ ],
+ 'body' => [
+ 'required' => 'Post body cannot be empty.',
+ ],
+ ];
+ }
+}
+```
+
+## Authorization
+
+Override `isAuthorized()` to control whether the current user is allowed to make this request. Return `false` to reject the request with a 403 Forbidden response before validation even runs.
+
+```php
+namespace App\Requests;
+
+use CodeIgniter\HTTP\FormRequest;
+
+class StorePostRequest extends FormRequest
+{
+ public function rules(): array
+ {
+ return [
+ 'title' => 'required|min_length[3]',
+ 'body' => 'required',
+ ];
+ }
+
+ public function isAuthorized(): bool
+ {
+ // Only authenticated users may submit posts.
+ return auth()->loggedIn();
+ }
+}
+```
+
+!!! note "Note"
+ The `isAuthorized()` check runs before `prepareForValidation()` and before validation itself. An unauthorized request never reaches the validation stage.
+
+## Preparing Data Before Validation
+
+Override `prepareForValidation(array $data): array` to normalize or derive input values before the validation rules are applied. The method receives the same data array that will be passed to the validator and must return the (possibly modified) array. This is useful for computed fields such as slugs, normalized phone numbers, or trimmed strings.
+
+```php
+namespace App\Requests;
+
+use CodeIgniter\HTTP\FormRequest;
+
+class StorePostRequest extends FormRequest
+{
+ public function rules(): array
+ {
+ return [
+ 'title' => 'required|min_length[3]',
+ 'slug' => 'required|is_unique[posts.slug]',
+ ];
+ }
+
+ protected function prepareForValidation(array $data): array
+ {
+ // Derive the slug from the title so it is available for the
+ // is_unique rule before validation runs.
+ $data['slug'] = url_title($data['title'] ?? '', '-', true);
+
+ return $data;
+ }
+}
+```
+
+!!! note "Note"
+ When validation fails and the default redirect response is used, `old()` returns the prepared validation data. Use `getValidated()` to access the processed data after a successful request.
+
+## Customizing the Data Source
+
+By default `validationData()` selects the appropriate data source automatically based on the HTTP method and `Content-Type` header:
+
+- **JSON request** (`Content-Type: application/json`) -> decoded JSON body
+- **PUT / PATCH / DELETE / QUERY** (non-multipart) -> raw body via `getRawInput()`
+- **GET / HEAD** -> query-string parameters via `getGet()`
+- **Everything else** (POST, multipart) -> POST body via `getPost()`
+
+This avoids the pitfalls of [withRequest()](../libraries/validation.md#withrequest), which mixes GET and POST data via `getVar()`.
+
+Override `validationData()` when you need a different data source - for example, to merge GET and POST parameters:
+
+```php
+namespace App\Requests;
+
+use CodeIgniter\HTTP\FormRequest;
+
+class SearchRequest extends FormRequest
+{
+ public function rules(): array
+ {
+ return ['q' => 'required|min_length[2]'];
+ }
+
+ // Merge query-string parameters and POST body for this endpoint.
+ protected function validationData(): array
+ {
+ return array_merge(
+ $this->request->getGet() ?? [],
+ $this->request->getPost() ?? [],
+ );
+ }
+}
+```
+
+## Customizing Failure Behavior
+
+Override `failedValidation()` and `failedAuthorization()` to take full control of what happens when a request is rejected. Both methods return a `ResponseInterface` that the framework sends to the client:
+
+```php
+namespace App\Requests;
+
+use CodeIgniter\HTTP\FormRequest;
+use CodeIgniter\HTTP\ResponseInterface;
+
+class StorePostRequest extends FormRequest
+{
+ public function rules(): array
+ {
+ return [
+ 'title' => 'required|min_length[3]',
+ 'body' => 'required',
+ ];
+ }
+
+ // Always respond with JSON, regardless of the request type.
+ protected function failedValidation(array $errors, array $preparedData): ResponseInterface
+ {
+ return service('response')->setStatusCode(422)->setJSON(['errors' => $errors]);
+ }
+
+ // Always respond with 403, regardless of the request type.
+ protected function failedAuthorization(): ResponseInterface
+ {
+ return service('response')->setStatusCode(403);
+ }
+}
+```
+
+The default `failedValidation()` returns a 422 JSON response for JSON request bodies or requests that prefer `application/json` through the `Accept` header. Otherwise, it redirects back with input and validation errors.
+
+!!! note "Note"
+ The `X-Requested-With: XMLHttpRequest` header alone does not select a JSON response. If an AJAX client expects JSON validation errors, send an `Accept: application/json` header. If your application needs HTML fragments for AJAX form failures, override `failedValidation()`.
+
+### Flashing Normalized Input
+
+If your `prepareForValidation()` transforms visible form fields (for example, trimming strings or canonicalizing values), the default redirect response flashes the prepared validation data as old input.
+
+If you override `failedValidation()` and still need to flash normalized input, use the second `$preparedData` argument. It contains the same data that was passed to validation:
+
+```php
+namespace App\Requests;
+
+use CodeIgniter\HTTP\FormRequest;
+use CodeIgniter\HTTP\ResponseInterface;
+
+class StorePostRequest extends FormRequest
+{
+ public function rules(): array
+ {
+ return [
+ 'title' => 'required|min_length[3]',
+ 'slug' => 'required|is_unique[posts.slug]',
+ ];
+ }
+
+ protected function prepareForValidation(array $data): array
+ {
+ $data['slug'] = url_title($data['title'] ?? '', '-', true);
+
+ return $data;
+ }
+
+ // Override while still flashing the prepared values on redirect.
+ protected function failedValidation(array $errors, array $preparedData): ResponseInterface
+ {
+ if (
+ $this->request->is('json')
+ || $this->request->negotiate('media', ['text/html', 'application/json'], true) === 'application/json'
+ ) {
+ return service('response')->setStatusCode(422)->setJSON(['errors' => $errors]);
+ }
+
+ // withInput() flashes validation errors. Then we replace old input with
+ // the same prepared values that were passed to validation.
+ $redirect = redirect()->back()->withInput();
+
+ service('session')->setFlashdata('_ci_old_input', [
+ 'get' => [],
+ 'post' => $preparedData,
+ ]);
+
+ return $redirect;
+ }
+}
+```
+
+The prepared data has not passed validation. After successful validation, use `getValidated()` or `getValidatedInput()` for trusted values.
+
+## How the Framework Resolves Form Requests
+
+When the router dispatches a controller method or closure route, the framework inspects the callable's parameter list using reflection. For each parameter whose type extends `FormRequest`:
+
+1. A new instance is created with the current request injected via the constructor.
+2. `isAuthorized()` is called. If it returns `false`, `failedAuthorization()` is called, and its response is returned to the client.
+3. `validationData()` collects the data to validate.
+4. `prepareForValidation()` receives that data and may modify it before the rules are applied.
+5. `run()` executes the validation rules. If it fails, `failedValidation()` is called, and its response is returned to the client.
+6. The validated data is stored internally and available via `getValidated()` and `getValidatedInput()`.
+7. The resolved FormRequest object is injected into the controller method or closure.
+
+The callable is never invoked if authorization or validation fails. Non-FormRequest parameters consume URI route segments in declaration order; variadic parameters receive all remaining segments.
+
+!!! note "Note"
+ Automatic injection does not apply to `_remap()` methods. See [\_remap() Methods](#\_remap-methods) above for the manual workaround.
diff --git a/docs/incoming/incomingrequest.md b/docs/incoming/incomingrequest.md
index ccae7d1db668..b443d02d2949 100644
--- a/docs/incoming/incomingrequest.md
+++ b/docs/incoming/incomingrequest.md
@@ -214,6 +214,25 @@ The `getVar()` method will pull from `$_REQUEST`, so it will return any data fro
!!! note "Note"
If the incoming request has a `Content-Type` header set to `application/json`, the `getVar()` method returns the JSON data instead of `$_REQUEST` data.
+### Typed Request Input
+
+!!! success "Available from version 4.8.0"
+
+`input()` returns a `CodeIgniter\HTTP\RequestInput` object. Use it to read values from a specific part of the request with typed fallback helpers:
+
+```php
+$page = $request->input()->get()->integer('page', 1);
+$remember = $request->input()->post()->boolean('remember', false);
+$name = $request->input()->json()->string('name');
+$published = $request->input()->raw()->boolean('published', false);
+```
+
+`input()->get()` reads query-string parameters. `input()->post()` reads POST body parameters. `input()->json()` reads JSON request body parameters. `input()->raw()` reads raw input parameters, like `getRawInput()`.
+
+These methods keep GET, POST, JSON, and raw data separate. They do not combine multiple request sources for you.
+
+These methods do not validate input. They are fallback-friendly helpers for reading raw request data. Use Validation or [Form Requests](form_requests.md#form-requests) when input must satisfy application rules before it is consumed.
+
### Getting JSON Data
You can grab the contents of `php://input` as a JSON stream with `getJSON()`.
@@ -271,7 +290,7 @@ $data = $request->getJsonVar('fizz', true);
!!! note "Note"
See the documentation for `dot_array_search()` in the `Array` helper for more information on "dot" notation.
-### Retrieving Raw Data (PUT, PATCH, DELETE)
+### Retrieving Raw Data (PUT, PATCH, DELETE, QUERY)
Finally, you can grab the contents of `php://input` as a raw stream with `getRawInput()`:
@@ -343,7 +362,7 @@ Filtering a POST variable would look like this:
$email = $request->getPost('email', FILTER_SANITIZE_EMAIL);
```
-All of the methods mentioned above support the filter type passed in as the second parameter, with the exception of `getJSON()` and `getRawInput()`.
+All of the methods mentioned above support the filter type passed in as the second parameter, with the exception of `getJSON()` and `getRawInput()`. The typed input helpers returned by `input()` do not accept filter parameters.
## Retrieving Headers
@@ -607,6 +626,11 @@ The same rule applies here: to retrieve the parameters with filtering, set the s
$request->getGet(['field1', 'field2'], FILTER_SANITIZE_FULL_SPECIAL_CHARS);
```
+#### input()
+
+- **Returns**: A typed input data selector.
+- **Return type**: `CodeIgniter\HTTP\RequestInput`
+
#### getPost([$index = null[, $filter = null[, $flags = null]]])
- **Parameters**
diff --git a/docs/incoming/index.md b/docs/incoming/index.md
index 2975bffe0a2d..58a77b58f2c4 100644
--- a/docs/incoming/index.md
+++ b/docs/incoming/index.md
@@ -10,6 +10,7 @@ Controllers handle incoming requests.
- [HTTP Messages](message.md)
- [Request Class](request.md)
- [IncomingRequest Class](incomingrequest.md)
+- [Form Requests](form_requests.md)
- [Content Negotiation](content_negotiation.md)
- [HTTP Method Spoofing](methodspoofing.md)
- [RESTful Resource Handling](restful.md)
diff --git a/docs/incoming/request.md b/docs/incoming/request.md
index 5c7fcf2e02f0..610b6231f577 100644
--- a/docs/incoming/request.md
+++ b/docs/incoming/request.md
@@ -30,39 +30,6 @@ echo $request->getIPAddress();
!!! important "Important"
This method takes into account the `Config\App::$proxyIPs` setting and will return the client IP address reported by the HTTP header for the allowed IP address.
-#### isValidIP($ip[, $which = ''])
-
-!!! danger "Deprecated since version 4.0.5"
- Use [Validation](../libraries/validation.md) instead.
-
-!!! important "Important"
- This method is deprecated. It will be removed in future releases.
-
-- **Parameters**
- - **$ip** `string` IP address
- - **$which** `string` IP protocol (`ipv4` or `ipv6`)
-
-- **Returns**: true if the address is valid, false if not
-
-- **Return type**: `bool`
-
-Takes an IP address as input and returns true or false (boolean) depending on whether it is valid or not.
-
-!!! note "Note"
- The `$request->getIPAddress()` method above automatically validates the IP address.
-
- ```php
- isValidIP($ip)) {
- echo 'Not Valid';
- } else {
- echo 'Valid';
- }
- ```
-
-Accepts an optional second string parameter of `ipv4` or `ipv6` to specify an IP format. The default checks for both formats.
-
#### getMethod()
- **Returns**: HTTP request method
@@ -126,36 +93,6 @@ To return an array of multiple `$_SERVER` values, pass all the required keys as
$request->getServer(['SERVER_PROTOCOL', 'REQUEST_URI']);
```
-#### getEnv([$index = null[, $filter = null[, $flags = null]]])
-
-!!! danger "Deprecated since version 4.4.4"
- This method has never worked. Use `env()` instead.
-
-- **Parameters**
- - **$index** `mixed` Value name
- - **$filter** `int` The type of filter to apply. A list of filters can be found in the [PHP manual](https://www.php.net/manual/en/filters.php).
- - **$flags** `int|array` Flags to apply. A list of flags can be found in the [PHP manual](https://www.php.net/manual/en/filter.constants.php#filter.constants.flags.generic).
-
-- **Returns**: `$_ENV` item value if found, null if not
-
-- **Return type**: `mixed`
-
-This method is identical to the `getPost()`, `getGet()` and `getCookie()` methods from the [IncomingRequest Class](incomingrequest.md), only it fetches env data (`$_ENV`):
-
-```php
-getEnv('some_data');
-```
-
-To return an array of multiple `$_ENV` values, pass all the required keys as an array.
-
-```php
-getEnv(['CI_ENVIRONMENT', 'S3_BUCKET']);
-```
-
#### setGlobal($method, $value)
- **Parameters**
diff --git a/docs/incoming/routing.md b/docs/incoming/routing.md
index 0df5bac7ef1f..26a1dee716ce 100644
--- a/docs/incoming/routing.md
+++ b/docs/incoming/routing.md
@@ -82,8 +82,14 @@ You can use any standard HTTP verb (GET, POST, PUT, DELETE, OPTIONS, etc.):
$routes->post('products', 'Product::feature');
$routes->put('products/1', 'Product::feature');
$routes->delete('products/1', 'Product::feature');
+$routes->query('products/search', 'Product::search');
```
+`QUERY` routes are for the HTTP QUERY method defined by RFC 10008. `QUERY` requests are safe and idempotent like GET, but send query content in the request body. Read that body explicitly with request input methods such as `$this->request->input()->json()` or `$this->request->input()->raw()`. Your controller should reject missing or unsupported `Content-Type` values for the query content it expects. Do not use `QUERY` for operations that create, update, delete, or otherwise change server state.
+
+!!! note "Note"
+ Your web server, proxy, or CORS configuration may need to allow the `QUERY` method before these requests can reach CodeIgniter. For browser-based cross-origin requests, ensure `QUERY` is included in your allowed CORS methods so preflight requests can succeed.
+
You can supply multiple verbs that a route should match by passing them in as an array to the `match()` method:
```php
@@ -299,6 +305,37 @@ $routes->addPlaceholder('uuid', '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}
$routes->get('users/(:uuid)', 'Users::show/$1');
```
+##### Sample Values for `spark routes`
+
+!!! success "Available from version 4.8.0"
+
+The [spark routes](../cli/spark_commands.md) command needs a concrete URI that matches each route in order to resolve the filters that would run for it. For the built-in placeholders (`num`, `segment`, `alpha`, and so on) it uses a hardcoded sample value. For custom placeholders it now attempts to generate a value from the placeholder's regular expression, so common patterns like `[A-Z]{3}[0-9]+` or the UUID example above work without any extra configuration.
+
+If the regex uses features the generator cannot reverse (lookarounds, backreferences, complex alternations, etc.), the Before/After Filters columns fall back to showing ``. You can fix that by providing an explicit sample in `app/Config/Routing.php`:
+
+```php
+ 'ABC123',
+ 'uuid' => '550e8400-e29b-41d4-a716-446655440000',
+ ];
+}
+```
+
+Each value must match its placeholder's regular expression. A value that does not match is ignored. Entries override the samples for the built-in placeholders too, not only custom ones.
+
+The resolution order is: a matching value declared in `$placeholderSamples`, then the built-in sample for standard placeholders, then an auto-generated value, then `` as a last resort.
+
+!!! note "Note"
+ This configuration only affects the `spark routes` display. Filter execution at request time already honors custom placeholders without any setup.
+
#### Regular Expressions
If you prefer, you can use regular expressions to define your routing rules. Any valid regular expression is allowed, as are back-references.
@@ -405,7 +442,7 @@ $routes->environment('development', static function ($routes) {
This method exists only for backward compatibility. Do not use it in new projects. Even if you are already using it, we recommend that you use another, more appropriate method.
!!! warning "Warning"
- While the `add()` method seems to be convenient, it is recommended to always use the HTTP-verb-based routes, described above, as it is more secure. If you use the [CSRF protection](../libraries/security.md), it does not protect **GET** requests. If the URI specified in the `add()` method is accessible by the GET method, the CSRF protection will not work.
+ While the `add()` method seems to be convenient, it is recommended to always use the HTTP-verb-based routes, described above, as it is more secure. If you use the [CSRF protection](../libraries/security.md), it does not protect **GET** or **QUERY** requests. If the URI specified in the `add()` method is accessible by the GET or QUERY method, the CSRF protection will not work.
It is possible to define a route with any HTTP verb. You can use the `add()` method:
diff --git a/docs/index.md b/docs/index.md
index 8d067cd7e364..6e2f26bd8dec 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -53,6 +53,7 @@
- [Logging Information](general/logging.md)
- [Error Handling](general/errors.md)
- [Web Page Caching](general/caching.md)
+ - [Context](general/context.md)
- [AJAX Requests](general/ajax.md)
- [Code Modules](general/modules.md)
- [Managing your Applications](general/managing_apps.md)
@@ -69,6 +70,7 @@
- [HTTP Messages](incoming/message.md)
- [Request Class](incoming/request.md)
- [IncomingRequest Class](incoming/incomingrequest.md)
+ - [Form Requests](incoming/form_requests.md)
- [Content Negotiation](incoming/content_negotiation.md)
- [HTTP Method Spoofing](incoming/methodspoofing.md)
- [RESTful Resource Handling](incoming/restful.md)
@@ -127,6 +129,7 @@
- [File Collections](libraries/file_collections.md)
- [Honeypot Class](libraries/honeypot.md)
- [Image Manipulation Class](libraries/images.md)
+ - [Atomic Locks](libraries/locks.md)
- [Pagination](libraries/pagination.md)
- [Publisher](libraries/publisher.md)
- [Security](libraries/security.md)
@@ -174,7 +177,9 @@
- [Running Controllers via CLI](cli/cli_controllers.md)
- [Spark Commands](cli/spark_commands.md)
- [Creating Spark Commands](cli/cli_commands.md)
+ - [Modern Spark Commands](cli/cli_modern_commands.md)
- [CLI Generators](cli/cli_generators.md)
+ - [Modern Generator Commands](cli/cli_modern_generators.md)
- [CLI Library](cli/cli_library.md)
- [CLI Signals](cli/cli_signals.md)
- [CLIRequest Class](cli/cli_request.md)
diff --git a/docs/installation/index.md b/docs/installation/index.md
index 4025c94ec505..56260890141e 100644
--- a/docs/installation/index.md
+++ b/docs/installation/index.md
@@ -17,6 +17,7 @@ However you choose to install and run CodeIgniter4, the latest [user guide](http
- [Troubleshooting](troubleshooting.md)
- [Deployment](deployment.md)
- [Change Logs](../changelogs/index.md)
+ - [Version 4.8.0](../changelogs/v4.8.0.md)
- [Version 4.7.7](../changelogs/v4.7.7.md)
- [Version 4.7.6](../changelogs/v4.7.6.md)
- [Version 4.7.5](../changelogs/v4.7.5.md)
@@ -100,6 +101,7 @@ However you choose to install and run CodeIgniter4, the latest [user guide](http
- [Version 4.0.0-alpha.1](../changelogs/v4.0.0-alpha.1.md)
- [Upgrading From a Previous Version](upgrading.md)
- [Backward Compatibility Notes](backward_compatibility_notes.md)
+ - [Upgrading from 4.7.x to 4.8.0](upgrade_480.md)
- [Upgrading from 4.7.6 to 4.7.7](upgrade_477.md)
- [Upgrading from 4.7.5 to 4.7.6](upgrade_476.md)
- [Upgrading from 4.7.4 to 4.7.5](upgrade_475.md)
diff --git a/docs/installation/upgrade_480.md b/docs/installation/upgrade_480.md
new file mode 100644
index 000000000000..96f46e2fb039
--- /dev/null
+++ b/docs/installation/upgrade_480.md
@@ -0,0 +1,199 @@
+# Upgrading from 4.7.x to 4.8.0
+
+Please refer to the upgrade instructions corresponding to your installation method.
+
+- [Composer Installation App Starter Upgrading](installing_composer.md#upgrading)
+- [Composer Installation Adding CodeIgniter4 to an Existing Project Upgrading](installing_composer.md#upgrading_1)
+- [Manual Installation Upgrading](installing_manual.md#upgrading)
+
+## Mandatory File Changes
+
+## Breaking Changes
+
+### Query Builder Bindings
+
+`getCompiledSelect()`, `getCompiledInsert()`, `getCompiledUpdate()`, `getCompiledDelete()`, `delete()` and `countAllResults()` now clear their bindings and binding counters after compilation or execution when their reset parameter is enabled (the default). This prevents accumulation when a builder is reused, but `getBinds()` now returns an empty array after these calls. Bindings are also cleared when the database query returns `false`.
+
+The [getCompiledSelect() documentation](../database/query_builder.md#builder-getcompiledselect) already states that the default reset should work just like `get()`. Clearing the bindings corrects that inconsistency while changing the observable result of `getBinds()`.
+
+To retain the query and its bindings, pass `false` as the first argument to any of the compiled methods or `countAllResults()`, or as the third argument to `delete()`: `$builder->delete('', null, false)`. This also retains query clauses such as WHERE and LIMIT.
+
+If you only need a snapshot of the bindings, read them before compilation or execution instead. When passing conditions directly to `delete()`, move them into `where()` first so their bindings are available before execution.
+
+Test mode retains bindings needed by the placeholders in its returned raw SQL. In particular, `getCompiledDelete()` needs these bindings to finish compilation and clears them afterwards according to its own reset parameter.
+
+### Query Builder JOIN Overrides
+
+`BaseBuilder::join()` and `BaseBuilder::compileJoinTable()` now accept `RawSql|string` for the table argument. If a custom Query Builder subclass overrides either method, change its `$table` parameter type from `string` to `RawSql|string`. The method body must also handle `RawSql` without escaping or prefixing it.
+
+### Console Exit Codes
+
+Previously, returning a non-integer value from a command run through `spark` would be treated as a successful execution (exit code `0`). Starting with v4.8.0, this behavior is still supported but will trigger a deprecation notice. Commands should now return an integer exit code to ensure proper behavior across all platforms.
+
+### Command Alternatives Visibility
+
+`CodeIgniter\CLI\Commands::getCommandAlternatives()` is now `public`. If you extend `Commands` and override this method, change the override's visibility from `protected` to `public`.
+
+### CLI Error Output
+
+Uncaught exceptions in `spark`, and errors shown with `BaseCommand::showError()` or `AbstractCommand::renderThrowable()`, are now printed to STDERR instead of STDOUT, so they no longer mix with output read by another program. The exit code is unchanged.
+
+If you redirect only STDOUT to a log file (`php spark my:command > my.log`), errors no longer reach that file. Redirect STDERR as well to keep them (`php spark my:command > my.log 2>&1`).
+
+A custom CLI error view that writes with `echo` instead of the `CLI` class still prints to STDOUT.
+
+### CLI Output Classes
+
+The static `CLI` class now delegates its output methods to instance classes: `Output`, `Terminal`, `ProgressBar`, `Table`, and `Color`. Calling the static methods keeps working, and the output is byte-identical, except that `CLI::beep()` now goes through the `InputOutput` transport and that `CLI::print()` of text ending in a line feed, or a progress bar drawn with `CLI::showProgress()`, no longer adds a blank line before the next `CLI::write()`.
+
+If you extend `CLI` and override `color()`, or read the `$lastWrite`, `$isColored`, `$io`, `$height`, `$width`, `$foreground_colors`, or `$background_colors` properties, those overrides and reads no longer take effect. The properties are still declared but unused. An override of `write()` is still reached by `newLine()`, `table()`, and `clearScreen()`, and an override of `fwrite()` by `wait()` and `clearScreen()`, but no longer by `print()`, `error()`, `beep()`, or `showProgress()`. Replace a transport override with `CLI::setInputOutput()`, and a color or terminal override with an `Output` or `Terminal` subclass passed to `CLI::setOutput()`. Overriding `Output::color()` covers `print()`, `write()`, and `error()` as well, since they color through it.
+
+Tests that reached those properties through reflection should use the instances returned by `CLI::getOutput()` and `CLI::getTerminal()` instead.
+
+### CLI Prompt
+
+`CLI::prompt()`, `CLI::promptByKey()`, and `CLI::promptByMultipleKeys()` now delegate to the `Prompt` class, and ask and answer exactly as before.
+
+If you extend `CLI` and override `validate()`, the prompts no longer call it. Extend `Prompt` and override its `validate()` instead, then return your subclass from `getPrompt()`: the static `CLI::getPrompt()` in your `CLI` subclass, or `AbstractCommand::getPrompt()` in a modern command. To change only the validation, pass a validation instance of your own to the `Prompt` constructor. An override of `CLI::prompt()` is still reached by calls to `CLI::prompt()`, but no longer by `promptByKey()` or `promptByMultipleKeys()`.
+
+### Uploaded File Move Return Type
+
+`CodeIgniter\HTTP\Files\UploadedFileInterface::move()` now returns `static` instead of `bool`, matching `CodeIgniter\Files\File::move()` which `UploadedFile` extends.
+
+If you have a custom implementation of `UploadedFileInterface`, or a class extending `UploadedFile` that overrides `move()`, return the instance instead of `true`:
+
+```php
+// Before
+public function move(string $targetPath, ?string $name = null, bool $overwrite = false)
+{
+ // ...
+
+ return true;
+}
+
+// After
+public function move(string $targetPath, ?string $name = null, bool $overwrite = false)
+{
+ // ...
+
+ return $this;
+}
+```
+
+Calling code that only tests the result, such as `if ($file->move($path))`, needs no change because the returned instance is truthy. Code comparing the result strictly against `true` must be updated.
+
+### Outgoing Request Constructor
+
+`CodeIgniter\HTTP\OutgoingRequest::__construct()` now requires the `$uri` parameter, which was previously `?URI $uri = null`. Consequently `OutgoingRequest::getUri()` now returns `URI` instead of `URI|null`.
+
+Passing `null` only worked when a `Host` header was supplied in the same call, because the constructor's host check short-circuits before dereferencing the URI. Such calls must now pass a `URI`:
+
+```php
+// Before
+$request = new OutgoingRequest('GET', null, ['Host' => 'example.com']);
+
+// After
+$request = new OutgoingRequest('GET', new URI('http://example.com'), ['Host' => 'example.com']);
+```
+
+Any other call that omitted `$uri` or passed `null` already failed with `Call to a member function getHost() on null`, so it needs no migration.
+
+### RedirectException Default Status Code
+
+`CodeIgniter\HTTP\Exceptions\RedirectException` no longer redeclares the inherited `$code` property to store the status applied to a `Response` whose status is outside the 301-308 range. If you have a subclass that overrode `$code` for this purpose, override the new `$defaultStatusCode` property instead:
+
+```php
+// Before
+class MyRedirectException extends RedirectException
+{
+ protected $code = 307;
+}
+
+// After
+class MyRedirectException extends RedirectException
+{
+ protected int $defaultStatusCode = 307;
+}
+```
+
+### Config Environment Lookups
+
+`BaseConfig` now reads the environment once and reuses the result, instead of looking up every property of every config class. The matching rules and their order are unchanged. One edge case behaves differently: a `putenv()` call that does not also write `$_ENV` or `$_SERVER`, made after the first config class was built, may not be seen by config classes built later until `BaseConfig::reset()` runs. This applies whether the variable is added, changed, or removed. In Worker Mode, this cache persists across requests, so such changes may not be seen in later requests either. To make the change visible, also set the value in `$_ENV` (or unset it there), or call `BaseConfig::reset()`. Values loaded from the **.env** file are not affected, since they are written to `$_ENV` and `$_SERVER` as well.
+
+Config classes that override `getEnvValue()` or `initEnvValue()` keep the previous behavior.
+
+### RouteCollection Identifier Transformation
+
+`RouteCollection` no longer applies `esc(strip_tags())` to namespace, controller, and method values, including custom controllers passed to `resource()` and `presenter()`. Applications relying on HTML tags being stripped or special characters being HTML-encoded must preprocess these values before passing them to the router. Values originating from untrusted sources should be restricted to an explicit allowlist of permitted handlers.
+
+## Breaking Enhancements
+
+### Log Handler Interface
+
+`CodeIgniter\Log\Handlers\HandlerInterface::handle()` now accepts a third parameter `array $context = []`.
+
+If you have a custom log handler that overrides the `handle()` method (whether implementing `HandlerInterface` directly or extending a built-in handler class), you must update your `handle()` method signature:
+
+```php
+// Before
+public function handle($level, $message): bool
+
+// After
+public function handle($level, $message, array $context = []): bool
+```
+
+The context array may contain the CI global context data under the `HandlerInterface::GLOBAL_CONTEXT_KEY` (`'_ci_context'`) key when `$logGlobalContext` is enabled in `Config\Logger`.
+
+## Project Files
+
+Some files in the **project space** (root, app, public, writable) received updates. Due to these files being outside of the **system** scope they will not be changed without your intervention.
+
+!!! note "Note"
+ There are some third-party CodeIgniter modules available to assist with merging changes to the project space: [Explore on Packagist](https://packagist.org/explore/?query=codeigniter4%20updates).
+
+### Content Changes
+
+The following files received significant changes (including deprecations or visual adjustments) and it is recommended that you merge the updated versions with your application:
+
+#### Config
+
+- **app/Config/Filters.php**
+ - Added a new filter named `requestid` that adds a unique request ID to each request in the application's context.
+
+- **app/Config/Generators.php**
+ - `Config\Generators::$views` added entries for `make:request`, `make:test`, and `make:transformer`, and dropped the stale `session:migration` entry.
+
+- **app/Config/Logger.php**
+ - `Config\Logger::$handlers` added a new key `stopChainOnFailure` to the `FileHandler` and `ErrorlogHandler` settings.
+
+- **app/Config/Mimes.php**
+ - `Config\Mimes::$mimes` added a new key `md` for Markdown files.
+
+- **app/Config/Routing.php**
+ - `Config\Routing::$placeholderSamples` was added to provide sample values for custom route placeholders so the `spark routes` command can resolve their filters.
+
+- **app/Config/Security.php**
+ - `Config\Security::$csrfFetchMetadata` and `Config\Security::$csrfFetchMetadataRejectSameSite` were added for Fetch Metadata based CSRF protection.
+
+#### Error Views
+
+- **app/Views/errors/cli/error_exception.php**
+ - Shows the backtrace also when the command runs with `-v`.
+
+- **app/Views/errors/html/debug.css**
+ - Added styles for the **Copy Details** button.
+
+- **app/Views/errors/html/debug.js**
+ - Added clipboard handling for the **Copy Details** button.
+
+- **app/Views/errors/html/error_exception.php**
+ - Added a **Copy Details** button to detailed HTML exception pages.
+
+- **app/Views/errors/html/error_report.php**
+ - Added a Markdown error report partial used by the **Copy Details** button.
+
+### All Changes
+
+This is a list of all files in the **project space** that received changes; many will be simple comments or formatting that have no effect on the runtime:
+
+- @TODO
diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md
index b7ffc93ad405..3e2fa36c42b0 100644
--- a/docs/installation/upgrading.md
+++ b/docs/installation/upgrading.md
@@ -12,6 +12,7 @@ For example, the original **app/Config/App.php** will be located in **vendor/cod
If you don't know what version of CodeIgniter you are currently running, you can get it from [the Debug Toolbar](../testing/debugging.md#the-debug-toolbar), or simply echo the constant `\CodeIgniter\CodeIgniter::CI_VERSION`.
- [Backward Compatibility Notes](backward_compatibility_notes.md)
+- [Upgrading from 4.7.x to 4.8.0](upgrade_480.md)
- [Upgrading from 4.7.6 to 4.7.7](upgrade_477.md)
- [Upgrading from 4.7.5 to 4.7.6](upgrade_476.md)
- [Upgrading from 4.7.4 to 4.7.5](upgrade_475.md)
diff --git a/docs/libraries/caching.md b/docs/libraries/caching.md
index b9109dcdd4ca..5bb93395e40e 100644
--- a/docs/libraries/caching.md
+++ b/docs/libraries/caching.md
@@ -117,11 +117,11 @@ Example:
$foo = $cache->get('my_cached_item');
```
-#### remember(string $key, int $ttl, Closure $callback)
+#### remember(string $key, callable|int $ttl, Closure $callback)
- **Parameters**
- **$key** `string` Cache item name
- - **$ttl** `int` Time to live in seconds
+ - **$ttl** `callable|int` Time to live in seconds
- **$callback** `Closure` Callback to invoke when the cache item returns null
- **Returns**: The value of the cache item
@@ -130,6 +130,34 @@ $foo = $cache->get('my_cached_item');
Gets an item from the cache. If `null` was returned, this will invoke the callback and save the result. Either way, this will return the value.
+The `$ttl` parameter may also be a callable, allowing the TTL to be determined dynamically at runtime. This is especially useful when the expiration time depends on the computed value or requires an expensive calculation.
+
+When a callable is provided, it is executed only on a cache miss, after the callback has been invoked. The callable always receives the computed value as its first argument:
+
+```php
+remember('key', static fn ($value) => 60, static fn () => fetchData());
+
+// Value-aware TTL
+$cache->remember(
+ 'key',
+ static fn ($value) => $value->expires_at - time(),
+ static fn () => fetchData(),
+);
+```
+
+This ensures that TTL computation is deferred until necessary and avoids unnecessary overhead when the cache item already exists.
+
+!!! note "Note"
+ Prior to v4.8.0, the second parameter only accepted an integer TTL value. The ability to pass a callable was added in v4.8.0.
+
+!!! note "Note"
+ When using the APCu cache handler, providing a callable TTL disables the use of `apcu_entry()` and falls back to a manual cache retrieval and storage process. As a result, the operation is no longer atomic and may be subject to race conditions under high concurrency.
+
+ If atomic behavior is required, use an integer TTL value.
+
#### save(string $key, $data[, int $ttl = 60])
- **Parameters**
diff --git a/docs/libraries/curlrequest.md b/docs/libraries/curlrequest.md
index c3cbefcd0fa1..435bf9200bc6 100644
--- a/docs/libraries/curlrequest.md
+++ b/docs/libraries/curlrequest.md
@@ -195,8 +195,11 @@ $client->options('http://example.com');
$client->patch('http://example.com');
$client->put('http://example.com');
$client->post('http://example.com');
+$client->query('http://example.com');
```
+The `query()` shortcut sends an HTTP QUERY request. QUERY requests usually include query content, so use options such as `json`, `form_params`, or `body` to send the expected body. This is different from the `query` request option, which adds URL query variables to the request URI.
+
#### Base URI
A `baseURI` can be set as one of the options during the instantiation of the class. This allows you to set a base URI, and then make all requests with that client using relative URLs. This is especially handy when working with APIs:
@@ -550,6 +553,58 @@ You can pass along data to send as query string variables by passing an associat
$client->request('GET', '/get', ['query' => ['foo' => 'bar']]);
```
+### retry
+
+!!! success "Available from version 4.8.0"
+
+The `retry` option retries failed requests before returning the final response or throwing an exception. For simple cases, set `retry` to the number of retry attempts:
+
+```php
+$response = $client->request('GET', 'https://api.example.com/items', [
+ 'retry' => 3,
+]);
+```
+
+For more control, pass an array:
+
+```php
+request('GET', 'https://api.example.com/items', [
+ 'retry' => [
+ 'max_retries' => 3,
+ 'delay' => [100, 500, 1000],
+ 'max_delay' => 5000,
+ 'status_codes' => [429, 500, 502, 503, 504],
+ 'curl_errors' => true,
+ 'respect_retry_after' => true,
+ ],
+]);
+```
+
+The available retry settings are:
+
+- `max_retries`: Number of retries after the initial request. The default is `3`.
+- `delay`: Delay in milliseconds before retrying. This may be an integer or a list of integers for simple backoff. If the list is shorter than the retry count, the last value is reused. The default is `1000`.
+- `max_delay`: Maximum delay in milliseconds. This caps both the configured `delay` and a valid `Retry-After` header. Set to `0` for no maximum delay. The default is `30000`.
+- `status_codes`: HTTP status codes that should be retried. The default is `[429, 503, 504]`.
+- `curl_errors`: Whether to retry transient cURL errors. The default is `false`.
+- `respect_retry_after`: Whether to use a valid `Retry-After` header instead of the configured `delay`. The default is `true`.
+
+When `respect_retry_after` is enabled, a valid `Retry-After` header takes priority over the configured `delay`. The header may be either a number of seconds or an HTTP date. If the header is invalid, the configured `delay` is used instead.
+
+Because `Retry-After` is supplied by the remote server, it is capped by `max_delay` by default to avoid unexpectedly long waits in the current PHP process.
+
+When `curl_errors` is enabled, only DNS resolution failures, connection failures, timeouts, and send or receive failures are retried.
+
+When [http_errors](#http_errors) is enabled, an HTTP error response is retried first if its status code is configured in `status_codes`. If all retry attempts are exhausted, the final HTTP error response throws the same exception as a request without retries.
+
+!!! note "Note"
+ Retry delays block the current PHP process. The total request time may exceed the configured [timeout](#timeout) because each retry attempt has its own cURL timeout and retry delays are added between attempts.
+
+!!! warning "Warning"
+ Be careful when retrying non-idempotent requests such as `POST` or `PATCH`. The remote server may receive the request more than once.
+
### timeout
By default, cURL functions are allowed to run as long as they take, with no time limit. You can modify this with the `timeout` option. The value should be the number of seconds you want the functions to execute for. Use 0 to wait indefinitely:
diff --git a/docs/libraries/encryption.md b/docs/libraries/encryption.md
index bcd624f70fe8..3da156c48251 100644
--- a/docs/libraries/encryption.md
+++ b/docs/libraries/encryption.md
@@ -265,6 +265,27 @@ The framework automatically parses the comma-separated string into an array and
!!! important "Important"
The `previousKeys` feature is for **decryption fallback only**. All new encryption operations always use the current `key`. If you pass an explicit key via the `$params` argument to `encrypt()` or `decrypt()`, the previousKeys fallback will not be used.
+#### Rotating with the `key:rotate` Command
+
+!!! success "Available from version 4.8.0"
+
+Step 2 above (demoting the current `key` and generating a new one) can be performed with the `key:rotate` spark command, which edits the **.env** file in place:
+
+```
+php spark key:rotate
+```
+
+The command reads `encryption.key` from your environment, prepends it to `encryption.previousKeys` (newest first, deduplicated), and writes a fresh `encryption.key`. Useful options:
+
+- `--prefix` (`hex2bin` or `base64`, default `hex2bin`) and `--length` (positive integer, default `32`) control how the new key is generated, mirroring `key:generate`.
+- `--keep=N` caps the retained `previousKeys` list to the `N` most recent entries. `N` must be a non-negative integer; `0` (the default) keeps every previous key.
+- `--force` / `-f` skips the interactive confirmation. Required when running with `--no-interaction`.
+
+All three options are validated up-front, so an invalid value cannot leave the **.env** file half-rotated.
+
+!!! warning "Warning"
+ `key:rotate` is not safe under concurrent execution. The command edits the **.env** file without taking a file lock, so two operators (or two automation runs) rotating at the same time can lose rotated-out keys.
+
### Padding
Sometimes, the length of a message may provide a lot of information about its nature. If a message is one of "yes", "no" and "maybe", encrypting the message doesn't help: knowing the length is enough to know what the message is.
diff --git a/docs/libraries/images.md b/docs/libraries/images.md
index 3dfc298638c9..79cc68fe404d 100644
--- a/docs/libraries/images.md
+++ b/docs/libraries/images.md
@@ -80,7 +80,10 @@ This example would take the same image and first fix any mobile phone orientatio
### Image Quality
-`save()` can take an additional parameter `$quality` to alter the resulting image quality. Values range from 0 to 100, with 90 being the framework default. This parameter only applies to JPEG and WebP images, and will be ignored otherwise:
+`save()` can take an additional parameter `$quality` to alter the resulting image quality. Values range from 0 to 100, with 90 being the framework default. This parameter only applies to JPEG, WebP, and AVIF images, and will be ignored otherwise:
+
+!!! note "Note"
+ For AVIF images, it is suggested to set the `$quality` parameter to 52. See also
!!! note "Note"
The parameter `$quality` for WebP can be used since v4.4.0.
diff --git a/docs/libraries/index.md b/docs/libraries/index.md
index baca35276daf..184464c11382 100644
--- a/docs/libraries/index.md
+++ b/docs/libraries/index.md
@@ -10,6 +10,7 @@
- [File Collections](file_collections.md)
- [Honeypot Class](honeypot.md)
- [Image Manipulation Class](images.md)
+- [Atomic Locks](locks.md)
- [Pagination](pagination.md)
- [Publisher](publisher.md)
- [Security](security.md)
diff --git a/docs/libraries/locks.md b/docs/libraries/locks.md
new file mode 100644
index 000000000000..b211f9ff91af
--- /dev/null
+++ b/docs/libraries/locks.md
@@ -0,0 +1,195 @@
+# Atomic Locks
+
+!!! success "Available from version 4.8.0"
+
+Atomic locks provide a simple way to prevent the same task from running concurrently across requests, CLI commands, or workers that share the same cache storage.
+
+Locks are advisory. Your code must acquire the lock before entering the critical section, and release it when the work is finished.
+
+## Configuration
+
+The Locks library uses the Cache service. The cache handler must support owner-aware lock operations. The built-in **File**, **Redis**, and **Predis** cache handlers support atomic lock operations. The **Memcached** cache handler also supports locks, with Memcached-specific limitations described below.
+
+!!! note "Note"
+ Locks are most useful when all competing processes share the same cache storage. The File handler is suitable for a single server. For multiple application servers, use a shared handler such as Redis.
+
+!!! important "Important"
+ Locks are stored in the configured cache handler. Clearing or flushing that cache storage, for example with `cache()->clean()` or a Redis `FLUSHDB`, may remove active locks. Avoid clearing shared lock storage while lock-protected work is running, or use a dedicated cache store for locks when that separation is important.
+
+!!! note "Note"
+ Memcached lock support requires the `memcached` PHP extension. The older `memcache` extension does not provide the CAS operations used for owner-aware refresh and best-effort release. Memcached does not provide an atomic compare-and-delete command, so releasing a Memcached lock cannot provide the same owner-checked atomic release guarantee as Redis or Predis. A delayed release may delete a lock that expired and was acquired by another owner. Memcached locks may also be lost if the Memcached server restarts, evicts keys, or flushes its cache.
+
+!!! note "Note"
+ File-backed locks clear released and expired lock contents, but may leave empty lock files in the cache directory. These files do not represent active locks and may be removed by normal cache cleanup when no lock-protected work is running.
+
+## Example Usage
+
+You can create a lock through the `locks` service. The second argument is the lock TTL, in seconds. The TTL prevents abandoned locks from being held forever if a process exits unexpectedly.
+
+```php
+create('reports.daily-export', 300);
+
+if (! $lock->acquire()) {
+ return;
+}
+
+try {
+ // Run the work that must not overlap.
+} finally {
+ $lock->release();
+}
+```
+
+!!! warning "Warning"
+ If the work takes longer than the lock TTL, another process may acquire the same lock while the first process is still running. For long-running work, choose a TTL that comfortably covers the operation, call `refresh()` while the lock is held, or check `isAcquired()` before performing irreversible side effects.
+
+### Running a Callback
+
+The `run()` method acquires the lock, runs the callback, and releases the lock in a `finally` block.
+
+```php
+create('reports.daily-export', 300)
+ ->run(static fn () => build_daily_report());
+```
+
+If the lock cannot be acquired, `run()` returns `false` and the callback is not called.
+
+### Blocking
+
+The `block()` method waits up to the given number of seconds for the lock to become available:
+
+```php
+create('imports.customer-feed', 300);
+
+if ($lock->block(10)) {
+ try {
+ import_customer_feed();
+ } finally {
+ $lock->release();
+ }
+}
+```
+
+### Restoring a Lock by Owner
+
+Each acquired lock has an owner token. You may pass this token to another process and restore the lock there, for example to release a lock from a queued worker that continues work started by the current request.
+
+```php
+create('exports.monthly', 300);
+
+if ($lock->acquire()) {
+ queue_export_job($lock->owner());
+}
+
+// Later, in another process:
+$restored = service('locks')->restore('exports.monthly', $owner);
+$restored->release();
+```
+
+## Locks and Cache Handlers
+
+The default File cache handler supports locks, so locks work without additional configuration in a standard application.
+
+If the configured cache handler does not support locks, resolving the `locks` service or constructing a lock manager throws a `CodeIgniter\Lock\Exceptions\LockException`.
+
+Custom cache handlers can support locks by implementing `CodeIgniter\Cache\LockStoreProviderInterface` and returning a `CodeIgniter\Cache\LockStoreInterface` instance. This keeps lock support opt-in and does not require all cache handlers to implement lock operations.
+
+Custom lock stores must implement owner-aware acquisition, release, refresh, force release, and owner lookup methods. `acquireLock()` should atomically claim the lock for an owner token and TTL. `releaseLock()` and `refreshLock()` must only affect the lock when the supplied owner token still matches the current owner. `forceReleaseLock()` intentionally ignores ownership, and `getLockOwner()` should return `null` when the lock is absent or expired.
+
+## Class Reference
+
+## CodeIgniter\Lock
+
+### LockManager
+
+#### create(string $name[, int $ttl = 300[, ?string $owner = null]])
+
+- **Parameters**
+ - **$name** `string` The logical lock name.
+ - **$ttl** `int` Number of seconds before the lock expires.
+ - **$owner** `string|null` Optional owner token.
+
+- **Returns**: A lock instance.
+
+- **Return type**: `LockInterface`
+
+Creates a lock for the given logical name.
+
+#### restore(string $name, string $owner[, int $ttl = 300])
+
+- **Parameters**
+ - **$name** `string` The logical lock name.
+ - **$owner** `string` The owner token.
+ - **$ttl** `int` Number of seconds before the lock expires.
+
+- **Returns**: A lock instance.
+
+- **Return type**: `LockInterface`
+
+Restores a lock instance for an existing owner token.
+
+### LockInterface
+
+#### acquire()
+
+- **Returns**: `true` if the lock was acquired, `false` otherwise.
+- **Return type**: `bool`
+
+#### block(int $seconds)
+
+- **Parameters**
+ - **$seconds** `int` Maximum number of seconds to wait.
+
+- **Returns**: `true` if the lock was acquired, `false` otherwise.
+
+- **Return type**: `bool`
+
+#### run(Closure $callback[, int $waitSeconds = 0])
+
+- **Parameters**
+ - **$callback** `Closure` The callback to run while the lock is held.
+ - **$waitSeconds** `int` Maximum number of seconds to wait.
+
+- **Returns**: The callback result, or `false` if the lock was not acquired.
+
+- **Return type**: `mixed`
+
+#### release()
+
+- **Returns**: `true` if the lock was released by its owner.
+- **Return type**: `bool`
+
+#### forceRelease()
+
+- **Returns**: `true` if the lock was force released.
+- **Return type**: `bool`
+
+Releases the lock without checking the owner token.
+
+#### refresh([?int $ttl = null])
+
+- **Parameters**
+ - **$ttl** `int|null` Number of seconds before the lock expires.
+
+- **Returns**: `true` if the owned lock was refreshed.
+
+- **Return type**: `bool`
+
+#### isAcquired()
+
+- **Returns**: `true` if this lock instance still owns the lock.
+- **Return type**: `bool`
+
+#### owner()
+
+- **Returns**: The owner token.
+- **Return type**: `string`
diff --git a/docs/libraries/security.md b/docs/libraries/security.md
index d447854b71bb..dbbe58da8268 100644
--- a/docs/libraries/security.md
+++ b/docs/libraries/security.md
@@ -81,6 +81,58 @@ class Security extends BaseConfig
}
```
+#### Fetch Metadata CSRF Protection
+
+!!! success "Available from version 4.8.0"
+
+CodeIgniter can use Fetch Metadata request headers as a first-line CSRF check for unsafe browser requests. Fetch Metadata is a browser-supplied signal that helps the framework allow same-origin requests and reject cross-site requests before checking the CSRF token.
+
+When CSRF protection is enabled, new applications use Fetch Metadata first by default. You can configure this behavior in **app/Config/Security.php**:
+
+```php
+isAfter($time2); // false
$time2->isAfter($time1); // true
```
+#### between()
+
+!!! success "Available from version 4.8.0"
+
+Determines if the current instance is between two other times. By default, the start and end bounds are included. Pass `false` as the third `$inclusive` argument to exclude both bounds:
+
+```php
+between('2024-01-01 12:00:00', '2024-01-01 13:00:00'); // true
+$time->between('2024-01-01 13:00:00', '2024-01-01 12:00:00'); // true
+$time->between('2024-01-01 13:00:00', '2024-01-01 14:00:00', true, 'Europe/Warsaw'); // true
+$time->between('2024-01-01 12:30:00', '2024-01-01 13:00:00', false); // false
+```
+
+Like other comparison methods, the values can be `Time` instances, `DateTimeInterface` instances, or strings that `DateTime` can parse. String values may use a timezone string as the fourth parameter. Otherwise, they are parsed using the current instance's timezone. If `$start` is after `$end`, the arguments are swapped automatically.
+
+#### min()
+
+!!! success "Available from version 4.8.0"
+
+Returns the earlier of the current instance and the provided time:
+
+```php
+min($time2); // 2024-01-01 12:00:00 UTC
+$time2->min('2024-01-01 12:30:00'); // 2024-01-01 12:30:00 UTC
+$time2->min('2024-01-01 13:00:00', 'Europe/Warsaw'); // 2024-01-01 12:00:00 UTC
+$time1->min(); // earlier of $time1 and now
+```
+
+If `null` is provided, it compares against `Time::now()` in the current instance's timezone. String values may use a timezone string as the second parameter. Otherwise, they are parsed using the current instance's timezone. If both times are equal, the current instance is returned.
+
+#### max()
+
+!!! success "Available from version 4.8.0"
+
+Returns the later of the current instance and the provided time:
+
+```php
+max($time2); // 2024-01-01 13:00:00 UTC
+$time1->max('2024-01-01 12:30:00'); // 2024-01-01 12:30:00 UTC
+$time1->max('2024-01-01 13:00:00', 'Europe/Warsaw'); // 2024-01-01 12:00:00 UTC
+$time2->max(); // later of $time2 and now
+```
+
+If `null` is provided, it compares against `Time::now()` in the current instance's timezone. String values may use a timezone string as the second parameter. Otherwise, they are parsed using the current instance's timezone. If both times are equal, the current instance is returned.
+
#### isPast()
!!! success "Available from version 4.7.0"
diff --git a/docs/libraries/uri.md b/docs/libraries/uri.md
index bbbd258365d2..7b075b4c07fc 100644
--- a/docs/libraries/uri.md
+++ b/docs/libraries/uri.md
@@ -84,9 +84,10 @@ The scheme is frequently 'http' or 'https', but any scheme is supported, includi
getScheme(); // 'http'
-$uri->setScheme('https');
+
+$uri = $uri->withScheme('https');
+echo $uri->getScheme(); // 'https'
```
### Authority
@@ -234,6 +235,71 @@ You can add a value to the query variables collection without destroying the exi
$uri->addQuery('foo', 'bar');
```
+#### Changing Query Values Without Mutation
+
+!!! success "Available from version 4.8.0"
+
+The immutable query methods provide alternatives to the older mutable methods:
+
+| Mutable method | Immutable alternative |
+|-------------------|--------------------------------------|
+| `setQuery()` | `withQuery()` |
+| `setQueryArray()` | `withQueryArray()` |
+| `addQuery()` | `withQueryVar()` / `withQueryVars()` |
+| `stripQuery()` | `withoutQueryVars()` |
+| `keepQuery()` | `withOnlyQueryVars()` |
+
+The `with*()` methods return a cloned URI instance and do not modify the original URI. The older mutable methods change the current URI instance.
+
+You can return a new URI instance with its query variables replaced by using the `withQuery()` and `withQueryArray()` methods:
+
+```php
+withQuery('page=2');
+// https://example.com/users?page=2
+
+$filtered = $uri->withQueryArray([
+ 'q' => 'alice',
+ 'role' => 'admin',
+]);
+// https://example.com/users?q=alice&role=admin
+```
+
+You can return a new URI instance with one or more query variables added or replaced by using the `withQueryVar()` and `withQueryVars()` methods. Existing query variables are preserved unless they are replaced:
+
+```php
+withQueryVar('page', 2);
+// https://example.com/users?q=bob&page=2
+
+$filtered = $uri->withQueryVars([
+ 'q' => 'alice',
+ 'page' => 1,
+ 'role' => 'admin',
+]);
+// https://example.com/users?q=alice&page=1&role=admin
+```
+
+You can return a new URI instance with query variables removed or filtered by using the `withoutQueryVars()` and `withOnlyQueryVars()` methods:
+
+```php
+withoutQueryVars('page');
+// https://example.com/users?q=bob&role=admin
+
+$onlyFilters = $uri->withOnlyQueryVars('q', 'role');
+// https://example.com/users?q=bob&role=admin
+```
+
#### Filtering Query Values
You can filter the query values returned by passing an options array to the `getQuery()` method, with either an *only* or an *except* key:
@@ -317,15 +383,14 @@ echo $uri->getSegment(3, 'foo');
echo $uri->getSegment(4, 'bar');
// will throw an exception
echo $uri->getSegment(5, 'baz');
-// will print 'baz'
-echo $uri->setSilent()->getSegment(5, 'baz');
-// will print '' (empty string)
-echo $uri->setSilent()->getSegment(5);
```
!!! note "Note"
You can get the last +1 segment. When you try to get the last +2 or higher segment, an exception will be thrown by default. You can prevent exceptions from being thrown with the `setSilent()` method.
+ !!! danger "Deprecated since version 4.4.0"
+ `setSilent()` triggers a deprecation notice since v4.8.0 and will be removed in CodeIgniter 5.0.
+
You can get a count of the total segments:
```php
@@ -355,6 +420,9 @@ $segments = $uri->getSegments();
By default, some methods of this class may throw an exception. If you want to disable it, you can set a special flag that will prevent throwing exceptions.
+!!! danger "Deprecated since version 4.4.0"
+ This method is deprecated and will be removed in a future version. It is recommended to handle exceptions properly instead of disabling them.
+
```php
withRequest($request)->run()) {
```
!!! warning "Warning"
- When you use this method, you should use the [getValidated()](#getting-validated-data) method to get the validated data. Because this method gets JSON data from [$request->getJSON()](../incoming/incomingrequest.md#getting-json-data) when the request is a JSON request (`Content-Type: application/json`), or gets raw data from [$request->getRawInput()](../incoming/incomingrequest.md#retrieving-raw-data-put-patch-delete) when the request is a PUT, PATCH, or DELETE request and is not an HTML form post (`Content-Type: multipart/form-data`), or gets data from [$request->getVar()](../incoming/incomingrequest.md#getting-data), and an attacker could change what data is validated.
+ When you use this method, you should use the [getValidated()](#getting-validated-data) method to get the validated data. Because this method gets JSON data from [$request->getJSON()](../incoming/incomingrequest.md#getting-json-data) when the request is a JSON request (`Content-Type: application/json`), or gets raw data from [$request->getRawInput()](../incoming/incomingrequest.md#retrieving-raw-data-put-patch-delete-query) when the request is a PUT, PATCH, DELETE, or QUERY request and is not an HTML form post (`Content-Type: multipart/form-data`), or gets data from [$request->getVar()](../incoming/incomingrequest.md#getting-data), and an attacker could change what data is validated.
!!! note "Note"
The [getValidated()](#getting-validated-data) method can be used since v4.4.0.
@@ -540,6 +540,78 @@ if (! $this->validateData($data, [
$validData = $this->validator->getValidated();
```
+#### Typed Validated Input
+
+`getValidatedInput()` returns the same validated data as a `CodeIgniter\Input\ValidatedInput` object. Use it after validation succeeds when you want to read common controller values as strings, integers, floats, booleans, arrays, dates, or enums:
+
+!!! success "Available from version 4.8.0"
+
+```php
+use App\Enums\PostStatus;
+
+$validation = service('validation');
+$validation->setRules([
+ 'title' => 'required|string',
+ 'page' => 'permit_empty|integer',
+ 'rating' => 'permit_empty|decimal',
+ 'active' => 'permit_empty|in_list[0,1,true,false,yes,no,on,off]',
+ 'tags' => 'permit_empty|is_array',
+ 'published_at' => 'permit_empty|valid_date[Y-m-d]',
+ 'status' => 'permit_empty|in_list[draft,published]',
+]);
+
+$data = [
+ 'title' => 'Hello World',
+ 'page' => '2',
+ 'rating' => '4.5',
+ 'active' => 'true',
+ 'tags' => ['php', 'codeigniter'],
+ 'published_at' => '2026-05-04',
+ 'status' => 'published',
+];
+
+if (! $validation->run($data)) {
+ // The validation failed.
+ return;
+}
+
+$input = $validation->getValidatedInput();
+
+$title = $input->string('title');
+$page = $input->integer('page', 1);
+$rating = $input->float('rating', 0.0);
+$active = $input->boolean('active', false);
+$tags = $input->array('tags', []);
+$publishedAt = $input->date('published_at', 'Y-m-d');
+$status = $input->enum('status', PostStatus::class, PostStatus::DRAFT);
+```
+
+Validation decides whether input is acceptable. The typed input object only helps you consume values that already passed validation. If a present value cannot be read as the requested type, an `InvalidArgumentException` is thrown. This usually means the validation rules and the expected type do not match.
+
+`ValidatedInput` extends `CodeIgniter\Input\InputData`. This keeps generic typed input access reusable while adding validation-specific readers for dates and enums. `InputData` is fallback-friendly for raw input; `ValidatedInput` is strict because it represents data that has already passed validation.
+
+The typed input object has the following methods:
+
+All methods support dot-array syntax for nested validated data.
+
+- `get($key, $default = null)` returns the raw validated value. If the field is missing, it returns the default value.
+- `has($key)` returns whether the field exists in the validated data, even if its value is `null`.
+- `string($key, $default = null)` returns `string|null`. If the field is missing, it returns the default value or `null`.
+- `integer($key, $default = null)` returns `int|null`. If the field is missing, it returns the default value or `null`.
+- `float($key, $default = null)` returns `float|null`. If the field is missing, it returns the default value or `null`.
+- `boolean($key, $default = null)` returns `bool|null`. If the field is missing, it returns the default value or `null`.
+- `array($key, $default = null)` returns `array|null`. If the field is missing, it returns the default value or `null`.
+- `date($key, $format = null, $timezone = null, $default = null)` returns `CodeIgniter\I18n\Time` or `null`. If the field is missing, it returns the default value or `null`. Pass a format when the value should be parsed with a specific date format. The default value must be `null` or a `CodeIgniter\I18n\Time` instance.
+- `enum($key, $enumClass, $default = null)` returns an enum instance or `null`. The default value must be `null` or an instance of the requested enum class.
+
+Fields that are present with a `null` value return `null`. This lets you distinguish a missing optional field from a field that was validated as `null`.
+
+Use validation rules such as `integer`, `decimal`, `valid_date`, `in_list`, or a custom rule to ensure the value matches the type you plan to read. The `date()` method only parses the value; validation rules should enforce acceptable date formats and ranges. For strict calendar validation, add a rule such as `valid_date[Y-m-d]`.
+
+The `enum()` method accepts PHP enum class names. Backed enums are matched by their backing value, while unit enums are matched by case name.
+
+The `boolean()` method uses PHP's boolean validation behavior, so common form values like `"1"`, `"0"`, `"true"`, `"false"`, `"yes"`, `"no"`, `"on"`, and `"off"` are accepted.
+
### Saving Sets of Validation Rules to the Config File
A nice feature of the Validation class is that it permits you to store all your validation rules for your entire application in a config file. You organize the rules into "groups". You can specify a different group every time you run the validation.
@@ -961,6 +1033,12 @@ When showing field-specific errors, you can pass the alias as the second paramet
= $validation->showError('username', 'my_single') ?>
```
+## Form Requests
+
+!!! success "Available from version 4.8.0"
+
+For a higher-level approach that encapsulates validation rules, custom error messages, and authorization logic in a dedicated class, see [Form Requests](../incoming/form_requests.md).
+
## Creating Custom Rules
### Using Rule Classes
@@ -1026,6 +1104,8 @@ class MyRules
{
if ((int) $value % 2 !== 0) {
$error = lang('myerrors.evenError');
+ // You can also use {field}, {param}, and {value} placeholders:
+ // $error = 'The value of {field} is not even.';
return false;
}
@@ -1035,6 +1115,9 @@ class MyRules
}
```
+!!! note "Note"
+ Since v4.8.0, the `{field}`, `{param}`, and `{value}` placeholders are supported in `$error` messages and will be replaced with the field's human-readable label (or field name if no label is set), the rule parameter, and the submitted value respectively.
+
#### Using a Custom Rule
Your new custom rule could now be used just like any other rule:
@@ -1140,7 +1223,7 @@ $validation->setRules([
return true;
}
- $error = 'The value is not even.';
+ $error = 'The value of {field} is not even.';
return false;
},
@@ -1148,6 +1231,9 @@ $validation->setRules([
]);
```
+!!! note "Note"
+ Since v4.8.0, the `{field}`, `{param}`, and `{value}` placeholders are supported in `$error` messages and will be replaced with the field's human-readable label (or field name if no label is set), the rule parameter, and the submitted value respectively.
+
### Using Callable Rule
!!! success "Available from version 4.5.0"
@@ -1216,7 +1302,7 @@ class Form extends BaseController
return true;
}
- $error = 'The value is not even.';
+ $error = 'The value of {field} is not even.';
return false;
}
@@ -1225,6 +1311,9 @@ class Form extends BaseController
}
```
+!!! note "Note"
+ Since v4.8.0, the `{field}`, `{param}`, and `{value}` placeholders are supported in `$error` messages and will be replaced with the field's human-readable label (or field name if no label is set), the rule parameter, and the submitted value respectively.
+
## Available Rules
!!! note "Note"
diff --git a/docs/models/entities.md b/docs/models/entities.md
index 3c9218f309a0..3bdfef56ca51 100644
--- a/docs/models/entities.md
+++ b/docs/models/entities.md
@@ -374,7 +374,7 @@ Property casting affects both read (get) and write (set), but some types affect
#### Scalar Type Casting
-Properties can be cast to any of the following data types: **integer**, **float**, **double**, **string**, **boolean**, **object**, **array**, **datetime**, **timestamp**, **uri**, **int-bool** and **enum**. Add a question mark at the beginning of the type to mark the property as nullable, i.e., **?string**, **?integer**.
+Properties can be cast to any of the following data types: **integer**, **float**, **double**, **string**, **boolean**, **object**, **array**, **datetime**, **timestamp**, **uri**, **int-bool**, **enum**, **encrypted**, **encrypted-json**, and **encrypted-json-array**. Add a question mark at the beginning of the type to mark the property as nullable, i.e., **?string**, **?integer**.
!!! note "Note"
**int-bool** can be used since v4.3.0.
@@ -382,6 +382,12 @@ Properties can be cast to any of the following data types: **integer**, **float*
!!! note "Note"
**enum** can be used since v4.7.0.
+!!! note "Note"
+ **encrypted**, **encrypted-json** and **encrypted-json-array** can be used since v4.8.0.
+
+!!! note "Note"
+ Since v4.8.0, you can also pass parameters to **float** and **double** types to specify the number of decimal places and rounding mode, e.g., **float[2,even]**.
+
For example, if you had a User entity with an `is_banned` property, you can cast it as a boolean:
```php
@@ -557,6 +563,59 @@ class User extends Entity
}
```
+#### Encrypted Casting
+
+!!! success "Available from version 4.8.0"
+
+Encrypted casting encrypts string values when they are set and decrypts them when they are read. It uses the [Encryption](../libraries/encryption.md) service, so you must configure an encryption key before using it. The configured key is required for both writing new values and reading stored values back. The `encrypted` type accepts string values. Use `?encrypted` for nullable values.
+
+```php
+ 'encrypted',
+ ];
+}
+
+$user = new User();
+
+$user->secret_note = 'Internal billing note';
+
+echo $user->secret_note; // Internal billing note
+
+$raw = $user->toRawArray();
+
+echo $raw['secret_note']; // Base64-encoded encrypted value
+```
+
+The `encrypted-json` and `encrypted-json-array` types encode values as JSON before encryption and decode them after decryption. The `encrypted-json` type returns `stdClass` values, while `encrypted-json-array` returns arrays. Use `?encrypted-json` and `?encrypted-json-array` for nullable values. These values are still stored as encrypted text, not as queryable database JSON.
+
+Encrypted values are stored as Base64-encoded ciphertext. Use a `TEXT` column or a sufficiently large string column because the stored value is longer than the plain text value. Avoid narrow columns like `VARCHAR(255)` unless you have verified the maximum encrypted length for the values you will store.
+
+!!! warning "Warning"
+ Encrypted values cannot be searched, sorted, filtered, or checked for uniqueness by their plain text value in the database.
+
+!!! warning "Warning"
+ Do not use encrypted casting for passwords. Passwords should be hashed with PHP's password hashing functions.
+
+!!! note "Note"
+ `toArray()` and JSON serialization return decrypted values. Use `toRawArray()` when you need the encrypted value that will be stored.
+
+!!! note "Note"
+ If the stored value cannot be decrypted, an `EncryptionException` is thrown.
+
+!!! note "Note"
+ Encrypted casts follow the Encryption service's key rotation behavior. Values encrypted with a previous key can be decrypted when that key is configured in `previousKeys`. Save the value again to re-encrypt it with the current key. See [Rotating with the `key:rotate` Command](../libraries/encryption.md#rotating-with-the-keyrotate-command) for rotating keys.
+
+!!! note "Note"
+ Encryption is non-deterministic. Setting the same plain text value can produce a different encrypted value and mark the Entity attribute as changed.
+
#### Custom Casting
You can define your own conversion types for getting and setting data.
diff --git a/docs/models/model.md b/docs/models/model.md
index 0c6117397743..3db964ec480f 100644
--- a/docs/models/model.md
+++ b/docs/models/model.md
@@ -136,7 +136,8 @@ class UserModel extends Model
protected $returnType = 'array';
protected $useSoftDeletes = true;
- protected $allowedFields = ['name', 'email'];
+ protected $allowedFields = ['name', 'email'];
+ protected array $insertOnlyFields = [];
protected bool $allowEmptyInserts = false;
protected bool $updateOnlyChanged = true;
@@ -209,6 +210,45 @@ This array should be updated with the field names that can be set during `save()
!!! note "Note"
The [$primaryKey](#primarykey) field should never be an allowed field.
+#### $insertOnlyFields
+
+!!! success "Available from version 4.8.0"
+
+This array may contain fields that can be set during `insert()` and `insertBatch()` calls, but should be removed from `update()`, `updateBatch()`, and update-side `save()` calls.
+
+This is useful for values that should be created once and then left unchanged through normal Model writes, such as public IDs, external references, or generated slugs.
+
+```php
+ 'int',
+ 'currency' => 'string',
+ 'amount' => 'float[2,even]',
+ ];
+ // ...
+}
+```
+
+!!! note "Note"
+ Prior to v4.8.0 the `float` type did not support any parameters. It simply converted the value to a float type in PHP without rounding.
#### csv
@@ -416,6 +487,60 @@ Enum casting supports:
- **Backed enums** (string or int) - The backing value is stored in the database
- **Unit enums** - The case name is stored in the database as a string
+#### encrypted
+
+!!! success "Available from version 4.8.0"
+
+Casting as `encrypted` encrypts string values before they are stored and decrypts them when they are retrieved. It uses the [Encryption](../libraries/encryption.md) service, so you must configure an encryption key before using it. The configured key is required for both writing new values and reading stored values back. The `encrypted` type accepts string values. Use `?encrypted` for nullable values.
+
+```php
+ 'encrypted',
+ ];
+}
+
+$userModel = model(UserModel::class);
+
+$id = $userModel->insert([
+ 'secret_note' => 'Internal billing note',
+]);
+
+$user = $userModel->find($id);
+
+echo $user['secret_note']; // Internal billing note
+```
+
+The `encrypted-json` and `encrypted-json-array` types encode values as JSON before encryption and decode them after decryption. The `encrypted-json` type returns `stdClass` values, while `encrypted-json-array` returns arrays. Use `?encrypted-json` and `?encrypted-json-array` for nullable values. These values are still stored as encrypted text, not as queryable database JSON.
+
+Encrypted values are stored as Base64-encoded ciphertext. Use a `TEXT` column or a sufficiently large string column because the stored value is longer than the plain text value. Avoid narrow columns like `VARCHAR(255)` unless you have verified the maximum encrypted length for the values you will store.
+
+!!! warning "Warning"
+ Encrypted values cannot be searched, sorted, filtered, or checked for uniqueness by their plain text value in the database.
+
+!!! warning "Warning"
+ Do not use encrypted casting for passwords. Passwords should be hashed with PHP's password hashing functions.
+
+!!! note "Note"
+ Model validation and write callbacks receive the encrypted value because Model Field Casting converts values before they are stored. Validate the plain text value before passing it to the Model when validation must inspect the plain text.
+
+!!! note "Note"
+ If the stored value cannot be decrypted, an `EncryptionException` is thrown.
+
+!!! note "Note"
+ If you rotate encryption keys, values encrypted with a previous key can be decrypted when that key is configured in `previousKeys`. Save the value again to re-encrypt it with the current key. See [Rotating with the `key:rotate` Command](../libraries/encryption.md#rotating-with-the-keyrotate-command) for rotating keys.
+
### Custom Casting
You can define your own conversion types.
@@ -889,6 +1014,87 @@ $model->save($job);
!!! note "Note"
If you find yourself working with Entities a lot, CodeIgniter provides a built-in [Entity class](entities.md) that provides several handy features that make developing Entities simpler.
+#### firstOrInsert()
+
+!!! success "Available from version 4.8.0"
+
+Finds the first row matching the given `$attributes`, or inserts a new row combining `$attributes` and `$values` when no match is found.
+
+Both parameters accept an array, a `stdClass` object, or an [Entity](entities.md):
+
+```php
+firstOrInsert(
+ ['email' => 'john@example.com'],
+ ['name' => 'John Doe', 'country' => 'US'],
+);
+
+// The above will trigger:
+//
+// 1) First it tries to find the record:
+// SELECT * FROM `users` WHERE `email` = 'john@example.com' LIMIT 1;
+//
+// 2) If no result is found, it inserts a new record:
+// INSERT INTO `users` (`email`, `name`, `country`)
+// VALUES ('john@example.com', 'John Doe', 'US');
+//
+// 3) Then it returns the found or newly created entity/row,
+// or false if something went wrong
+```
+
+`$attributes` is used as the WHERE condition for the lookup. If no record is found, a new row is inserted using the merged result of `$attributes` and `$values`. The `$values` data is only applied during insertion and is ignored when a matching record already exists.
+
+```php
+email = 'john@example.com';
+
+$user = $userModel->firstOrInsert($attrs, ['name' => 'John Doe', 'country' => 'US']);
+```
+
+The method returns the found or newly inserted row in the format defined by [$returnType](#returntype), or `false` on failure (e.g., validation error or database error when `DBDebug` is `false`).
+
+!!! note "Note"
+ A database **unique constraint** on the lookup column(s) is required for the method to be race-safe. Without it, two concurrent requests could both pass the initial lookup and attempt to insert, resulting in duplicate rows.
+
+ When a unique constraint is present, a concurrent insert is detected via `UniqueConstraintViolationException` and resolved automatically by performing a second lookup.
+
+#### withLockedRow()
+
+!!! success "Available from version 4.8.0"
+
+Reloads one row by primary key inside a transaction using [Lock for Update](../database/query_builder.md#lock-for-update), then runs the callback with the locked row and the current Model instance:
+
+```php
+withLockedRow($id, static function (object $account, $model): bool {
+ $account->balance -= 100;
+
+ if (! $model->save($account)) {
+ throw new \RuntimeException('Unable to save the account.');
+ }
+
+ return true;
+});
+```
+
+The method returns the callback return value. If the row is not found, the callback is not run and `null` is returned. If the transaction cannot begin or the transaction status fails without an exception, `false` is returned.
+
+Existing Model query constraints are applied to the locked lookup, so methods like `where()` and `withDeleted()` can be called before `withLockedRow()`.
+
+Returning `false` from the callback does not trigger a rollback. To explicitly roll back from the callback, throw an exception. Any exception thrown by the callback rolls back the transaction and is rethrown. Unsupported lock combinations throw the same database exceptions as `lockForUpdate()`.
+
+!!! important "Important"
+ The locked row is reloaded directly from the database, so Model find callbacks are not run for that lookup. Model methods called inside the callback, such as `save()` or `update()`, still follow the Model's callback setting.
+
+!!! note "Note"
+ Row locks are only useful when the database supports transactions and pessimistic locks. See [Lock for Update](../database/query_builder.md#lock-for-update) for driver support and limitations. If database transactions are disabled, this method follows the behavior of `transaction()` and runs the callback without starting a transaction.
+
#### Saving Dates
!!! success "Available from version 4.5.0"
@@ -1248,6 +1454,34 @@ class MyModel extends Model
}
```
+If you prefer disallowed fields to raise an exception instead of being silently removed, enable throwing on disallowed fields:
+
+```php
+throwOnDisallowedFields()
+ ->insert($data);
+```
+
+When throwing on disallowed fields is enabled, operation fields such as the primary key passed to `update()` or the index passed to `updateBatch()` may still be used to locate rows.
+
+If some allowed fields should only be set during insert operations, list them in [$insertOnlyFields](#insertonlyfields):
+
+```php
+asObject('User')->where('status', 'active')->findAll();
### Processing Large Amounts of Data
-Sometimes, you need to process large amounts of data and would run the risk of running out of memory. To make this simpler, you may use the `chunk()` method to get smaller chunks of data that you can then do your work on. The first parameter is the number of rows to retrieve in a single chunk. The second parameter is a Closure that will be called for each row of data.
+Sometimes, you need to process large amounts of data and would run the risk of running out of memory. This is best used during cron jobs, data exports, or other large tasks. To make this simpler, you can process the data in smaller, manageable pieces using the methods below.
-This is best used during cron jobs, data exports, or other large tasks.
+!!! note "Note"
+ The `chunk()`, `chunkRows()`, `chunkById()`, and `chunkRowsById()` methods do not trigger Model callbacks.
+
+#### chunk()
+
+You may use the `chunk()` method to get smaller chunks of data that you can then do your work on. The first parameter is the number of rows to retrieve in a single chunk. The second parameter is a Closure that will be called for each row of data.
```php
chunk(100, static function ($data) {
});
```
+#### chunkRows()
+
+!!! success "Available from version 4.8.0"
+
+On the other hand, if you want the entire chunk to be passed to the Closure at once, you can use the `chunkRows()` method.
+
+```php
+chunkRows(100, static function ($rows) {
+ // do something.
+ // $rows is an array of rows representing chunk of 100 items.
+});
+```
+
+#### chunkById()
+
+!!! success "Available from version 4.8.0"
+
+If you need to update or delete rows while processing them, use `chunkById()`. This method retrieves each chunk ordered by the model's primary key instead of using an offset, so deleting or updating rows already processed will not cause later rows to be skipped.
+
+```php
+where('country', 'US')
+ ->chunkById(100, static function ($data) {
+ // do something.
+ // $data is a single row of data.
+ });
+```
+
+#### chunkRowsById()
+
+!!! success "Available from version 4.8.0"
+
+If you want the entire ID-based chunk to be passed to the Closure at once, use `chunkRowsById()`.
+
+```php
+where('country', 'US')
+ ->chunkRowsById(100, static function ($rows) {
+ // do something.
+ // $rows is an array of rows representing a chunk of 100 items.
+ });
+```
+
+!!! warning "Warning"
+ ID-based chunking needs the model to define a primary key, and that primary key must be selected. If you customize the selected columns, make sure the primary key is included. When using joins, the model primary key must remain available under its normal field name, and each primary key value must appear only once in the result set. Otherwise, rows may be skipped. These methods manage their own primary key ordering and cannot be used with `orderBy()`, `groupBy()`, `limit()`, `offset()` or `union()`. You should also avoid changing primary key values while processing chunks. Since these methods add their own `WHERE` condition to each chunk query, group any `orWhere()` conditions that should be evaluated together.
+
## Working with Query Builder
### Getting Query Builder for the Model's Table
diff --git a/docs/outgoing/api_transformers.md b/docs/outgoing/api_transformers.md
index 59dcf75717df..4a8e259922e8 100644
--- a/docs/outgoing/api_transformers.md
+++ b/docs/outgoing/api_transformers.md
@@ -223,6 +223,65 @@ class UserTransformer extends BaseTransformer
Now, even if a client requests `/users/1?fields=email`, an `ApiException` will be thrown because `email` is not in the allowed fields list.
+### Sparse Fieldsets (Per-Type Filtering)
+
+!!! success "Available from version 4.8.0"
+
+The flat `fields` parameter filters the root resource. When a response also embeds [related resources](#including-related-resources), you can filter each related type independently using the bracketed *sparse fieldset* form `fields[]=...`.
+
+To opt a transformer into this, declare its resource type via the `$resourceType` property:
+
+```php
+ $resource['id'],
+ 'title' => $resource['title'],
+ 'slug' => $resource['slug'],
+ 'body' => $resource['body'],
+ 'user_id' => $resource['user_id'],
+ ];
+ }
+}
+```
+
+A matching `fields[]` fieldset applies whether the transformer is used as the root resource or as an included resource. The flat `fields=...` form still applies to the root transformer only.
+
+Each transformer reads the fieldset that matches its own `$resourceType`. Given the `UserTransformer` from [Including Related Resources](#including-related-resources) (which includes posts), a request:
+
+```text
+/users/1?include=posts&fields[posts]=id,slug
+```
+
+scopes the fields **only** for the embedded posts, leaving the user fields untouched:
+
+```json
+{
+ "id": 1,
+ "name": "John Doe",
+ "email": "john@example.com",
+ "posts": [
+ {
+ "id": 1,
+ "slug": "first-post"
+ }
+ ]
+}
+```
+
+When filtering multiple resource types in one request, use the bracketed form for each type, such as `fields[users]=id,name&fields[posts]=id,slug`.
+
## Including Related Resources
Transformers support loading related resources through the `include` query parameter. This follows a common API pattern where clients can specify which relationships they want included. While relationships are the most frequent use case, you can include any additional data you want by defining custom include methods.
@@ -293,7 +352,7 @@ The response would include:
```
!!! note "Note"
- The `fields` and `include` query parameters describe the **root** resource only. Transformers instantiated inside an `include*()` method (such as `PostTransformer` above) are treated as nested resources: when created **without an explicit request** they do **not** inherit the root request's `fields`/`include` state. This prevents the parent's query parameters from leaking into related resources, which could otherwise cause incorrect field filtering or unexpected/recursive includes. If you deliberately pass a request to a nested transformer, that request's scope is honored.
+ The flat `fields=...` and `include` query parameters describe the **root** resource only. Transformers instantiated inside an `include*()` method (such as `PostTransformer` above) are treated as nested resources: when created **without an explicit request** they do **not** inherit the root request's flat `fields`/`include` state. Use `fields[]=...` with a matching `$resourceType` to filter a nested resource. This prevents the parent's query parameters from leaking into related resources, which could otherwise cause incorrect field filtering or unexpected/recursive includes. If you deliberately pass a request to a nested transformer, that request's scope is honored.
### Restricting Available Includes
@@ -517,6 +576,14 @@ $result = $transformer->transform(null); // No resource passed
### BaseTransformer
+#### $resourceType
+
+- **Type**: `string|null`
+
+!!! success "Available from version 4.8.0"
+
+The resource type this transformer represents. Used to resolve per-type sparse fieldsets from the request (e.g. a type of `'posts'` reads `?fields[posts]=...`). Defaults to `null`, in which case only the flat `?fields=...` parameter applies, and only to the root transformer. See [Sparse Fieldsets (Per-Type Filtering)](#sparse-fieldsets-per-type-filtering).
+
#### \__construct(?IncomingRequest $request = null)
- **Parameters**
diff --git a/docs/outgoing/csp.md b/docs/outgoing/csp.md
index 42dbd14bc021..31f8c9942ce1 100644
--- a/docs/outgoing/csp.md
+++ b/docs/outgoing/csp.md
@@ -92,6 +92,33 @@ $csp->addWorkerSrc('workers.example.com');
The first parameter to each of the "add" methods is an appropriate string value, or an array of them.
+### Enable or Disable CSP at Runtime
+
+!!! success "Available from version 4.8.0"
+
+You can enable or disable CSP for the current request by using the `enable()` and `disable()` methods on the CSP instance.
+
+For example, to enable/disable CSP for a specific response:
+
+```php
+response->getCSP();
+
+// Disable CSP for the request
+$csp->disable();
+
+// Re-enable CSP for the request
+$csp->enable();
+```
+
+The runtime value takes precedence over the `CSPEnabled` configuration value for the current request.
+
+These methods only affect the current request. They do not modify the application configuration. On subsequent requests, the `CSPEnabled` value in **app/Config/App.php** is used again.
+
+This allows applications to keep CSP enabled globally while temporarily disabling it for specific requests, or enable it only where required.
+
### Report Only
The `reportOnly()` method allows you to specify the default reporting treatment for subsequent sources, unless overridden.
@@ -200,3 +227,28 @@ In this case, you can use the functions `csp_script_nonce()` and `csp_style_nonc
. . .
```
+
+### Control Nonce Generation
+
+!!! success "Available from version 4.8.0"
+
+By default, both the script and style nonces are generated automatically. If you want to only generate one of them, you can set `$enableStyleNonce` or `$enableScriptNonce` to false in **app/Config/ContentSecurityPolicy.php**:
+
+```php
+response->download($name, $data)->inline();
```
+## Streaming Responses
+
+!!! success "Available from version 4.8.0"
+
+CodeIgniter provides a `StreamResponse` class that streams its body to the client as it is generated, instead of buffering the complete body first. This is useful for large dataset exports, AI/LLM token streaming, proxying upstream streams, or any content that is produced while it is being sent. Streaming keeps memory usage low and improves time-to-first-byte for large responses.
+
+Use the `$this->response->stream()` method with a callback:
+
+```php
+response
+ ->stream(static function (StreamResponse $stream) {
+ $userModel = model('UserModel');
+ $offset = 0;
+
+ // Fetch rows in batches to keep memory usage low
+ while ($users = $userModel->findAll(500, $offset)) {
+ foreach ($users as $user) {
+ // write() returns false once the client has disconnected
+ if (! $stream->write(json_encode($user) . "\n", false)) {
+ return;
+ }
+ }
+
+ // Push the whole batch to the client at once
+ $stream->flush();
+
+ $offset += 500;
+ }
+ })
+ ->setContentType('application/x-ndjson');
+```
+
+The callback receives the `StreamResponse` instance. `StreamResponse::write()` returns `false` once the client has disconnected, so you can stop producing output early. By default, every `write()` flushes output to the client immediately. When writing many small chunks, pass `false` as the second argument and call `StreamResponse::flush()` at intervals instead - the example above flushes once per batch.
+
+The callback is not limited to `write()`: you may also write directly to `php://output` (for example, with `fputcsv()` for CSV exports), calling `flush()` and `StreamResponse::isClientConnected()` yourself.
+
+Alternatively, you may pass an iterable of string chunks (such as a generator), and each chunk is written and flushed in order:
+
+```php
+ 'started']) . "\n";
+
+ yield json_encode(['event' => 'finished']) . "\n";
+};
+
+return $this->response->stream($chunks());
+```
+
+### Headers and Status Code
+
+Set the content type, status code, and any custom headers **before** returning the response - anything set inside the callback will be too late. Unless you have already set it, `StreamResponse` applies `X-Accel-Buffering: no` to discourage intermediaries from buffering the stream. PHP's zlib output compression is turned off automatically, but if your web server or CDN compresses responses (e.g., nginx `gzip` or Apache `mod_deflate`), configure it to skip your streaming endpoints - compression can delay chunk delivery.
+
+!!! note "Note"
+ `stream()` and `eventStream()` create a new response instance. Headers, cookies, and status codes already set on the main response are not copied. Set them on the returned `StreamResponse` or `SSEResponse` instance before returning it.
+
+The response is streamed: output buffering is disabled, the PHP time limit is removed, and the session is closed to avoid blocking other requests. After filters still run and may set headers, but they must not rely on the response body. View rendering and decorators are not applied - stream your output in the callback.
+
+If [Content Security Policy](csp.md) is enabled, the `Content-Security-Policy` header is sent as usual. However, nonce placeholders cannot be replaced in a streamed body. When streaming HTML that needs a nonce, call `$this->response->getCSP()->getScriptNonce()` (or `getStyleNonce()`) **before** returning the response and embed the returned value in your output yourself.
+
+`StreamResponse` does not set a `Content-Length` header by default, since the body length is typically unknown in advance. Without it, clients cannot display download progress or resume interrupted transfers. If you do know the total size (for example, when proxying a stream of known length), you may set `Content-Length` yourself before returning the response.
+
+!!! note "Note"
+ To serve an existing file, prefer [DownloadResponse](#force-file-download), which sends proper `Content-Length` and `Content-Disposition` headers. Use `StreamResponse` when the content is generated while it is being sent.
+
+### Example: Proxying an Upstream Stream
+
+`StreamResponse` can forward a stream from another source - such as object storage or an internal API - to the client without buffering it in memory or writing it to disk:
+
+```php
+response->stream(static function (StreamResponse $stream) use ($source) {
+ // Forward the upstream file chunk by chunk, without buffering it in memory
+ try {
+ while (! feof($source)) {
+ $chunk = fread($source, 1_048_576);
+
+ if ($chunk === false || ! $stream->write($chunk)) {
+ break;
+ }
+ }
+ } finally {
+ fclose($source);
+ }
+});
+
+$stream->setContentType('application/pdf');
+
+return $stream;
+```
+
+The [development server](#development-server-limitations) and [production](#production-considerations) considerations described for SSE below apply to all streaming responses.
+
+## Server-Sent Events (SSE)
+
+!!! success "Available from version 4.8.0"
+
+CodeIgniter provides an `SSEResponse` for streaming [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) over HTTP. This is useful for long-lived connections where the server pushes events to the client. `SSEResponse` is a specialized [StreamResponse](#streaming-responses) that formats output according to the SSE protocol and forces the appropriate headers. Create it via the `$this->response->eventStream()` factory method, or instantiate `new SSEResponse(...)` directly.
+
+```php
+response->eventStream(static function (SSEResponse $sse) {
+ foreach (['Hello', 'World'] as $text) {
+ if (! $sse->event(['text' => $text])) {
+ break;
+ }
+
+ sleep(1);
+ }
+
+ $sse->event('[DONE]');
+});
+```
+
+The callback receives the `SSEResponse` instance. Use `event()`, `comment()`, or `retry()` to send SSE fields. If you pass an array to `event()`, it will be JSON-encoded. If encoding fails or the client disconnects, `event()` returns `false`.
+
+The response is streamed: output buffering is disabled, and the session is closed to avoid blocking other requests. Headers must be set **before** returning the response because anything set inside the callback will be too late.
+
+After filters still run. Any headers or cookies they set will be sent, but they must not rely on the response body. View rendering and decorators are not applied because the response body is not built - stream your output in the callback.
+
+### Custom Headers and Keep-Alive
+
+If you need custom headers, set them before returning the response. You can also use comments for keep-alive and configure the client retry interval:
+
+```php
+response->eventStream(static function (SSEResponse $sse) {
+ $sse->comment('keep-alive');
+
+ foreach (['one', 'two', 'three', 'four'] as $text) {
+ if (! $sse->event(['text' => $text])) {
+ break;
+ }
+ }
+
+ sleep(1);
+
+ $sse->retry(5000);
+});
+
+$sse->setHeader('X-Stream-Name', 'demo');
+
+return $sse;
+```
+
+### Development Server Limitations
+
+When testing SSE locally, keep in mind that PHP's built-in development server (used by `php spark serve`) is not designed to handle long-lived streaming connections effectively.
+
+Because an SSE connection remains open for an extended period, the built-in server may spend most of its capacity serving the stream. As a result, other requests may appear slow, blocked, or delayed while the SSE connection is active.
+
+Possible symptoms include:
+
+- normal HTTP requests appearing to hang or time out
+- API requests responding slowly
+- frontend requests remaining in a pending state
+
+For more realistic testing, prefer a server stack that handles concurrent requests better, such as Apache, nginx with PHP-FPM, or FrankenPHP.
+
+This behavior is a limitation of the development server environment, not of `SSEResponse` itself.
+
+### Production Considerations
+
+Some server stacks and CDNs buffer or compress responses (e.g., Apache with `mod_deflate`), which can break real-time SSE delivery. `SSEResponse` disables PHP output buffering, turns off zlib output compression, and sets `X-Accel-Buffering: no`. However, intermediaries may still buffer or compress, so configure your web server or CDN to disable buffering/compression for SSE endpoints.
+
+### Example: Product-Oriented Use Case
+
+The following example simulates a small notification stream to illustrate a more product-focused use case:
+
+```php
+get('user_id');
+
+return $this->response->eventStream(static function (SSEResponse $sse) use ($user_id) {
+ // Stream live notifications for the current user
+ $notificationModel = model(NotificationModel::class);
+
+ $lastId = 0;
+
+ // In a real app, you would typically keep the connection open indefinitely
+ for ($i = 0; $i < 6; $i++) {
+ $order = $lastId === 0 ? 'desc' : 'asc';
+
+ // On the first pass, pick the newest notification
+ // After that, stream any newer ones in order
+ $notification = $notificationModel->where('user_id', $user_id)
+ ->where('id >', $lastId)
+ ->orderBy('id', $order)
+ ->first();
+
+ if ($notification !== null) {
+ $lastId = (int) $notification['id'];
+
+ if (! $sse->event($notification, 'notification', (string) $lastId)) {
+ break;
+ }
+ } else {
+ // No new notifications yet: send a keep-alive comment
+ if (! $sse->comment('keep-alive')) {
+ break;
+ }
+ }
+
+ // Poll every 10 seconds
+ sleep(10);
+ }
+
+ // Ask the browser to retry in 60 seconds if the connection closes
+ $sse->retry(60000);
+});
+```
+
## HTTP Caching
Built into the HTTP specification are tools that help the client (often the web browser) cache the results. Used correctly, this can lead to a huge performance boost to your application because it will tell the client that they don't need to contact the server at all since nothing has changed. And you can't get faster than that.
diff --git a/docs/testing/cli.md b/docs/testing/cli.md
index 254a6f7361f3..d06717f0c8d4 100644
--- a/docs/testing/cli.md
+++ b/docs/testing/cli.md
@@ -20,12 +20,12 @@ You can replace the `InputOutput` class with `MockInputOutput` during test execu
Gets the output.
- If you call it like `$io->getOutput()`, it returns the whole output string.
-- If you specify `0` or a positive number, it returns the output array item. Each item has the output of a `CLI::fwrite()` call.
+- If you specify `0` or a positive number, it returns the output array item. Each item has the output of one write, such as a `CLI::write()` or `CLI::beep()` call.
- If you specify a negative number `-n`, it returns the last `n`-th item of the output array.
##### getOutputs(): array
-Returns the output array. Each item has the output of a `CLI::fwrite()` call.
+Returns the output array. Each item has the output of one write.
### How to Use
@@ -52,30 +52,54 @@ final class DbTableTest extends CIUnitTestCase
public function testDbTable(): void
{
- // Set MockInputOutput to CLI.
$io = new MockInputOutput();
CLI::setInputOutput($io);
- // User will input "a" (invalid value) and "0".
- $io->setInputs(['a', '0']);
+ $io->setInputs(['a', '1']);
command('db:table');
- // Get the whole output string.
$output = $io->getOutput();
- $expected = 'Which table do you want to see? [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]: a';
+ $expected = 'Please select correctly.';
$this->assertStringContainsString($expected, $output);
- $expected = 'Data of Table "db_migrations":';
+ $expected = 'Data of "db_migrations" table:';
$this->assertStringContainsString($expected, $output);
- // Remove MockInputOutput.
CLI::resetInputOutput();
}
}
```
+#### Running a Modern Command Against an Output
+
+!!! success "Available from version 4.8.0"
+
+A modern command can also be run directly against an `Output` built on the mock, which leaves the `CLI` class untouched once `run()` returns:
+
+```php
+run([], [], new Output($io));
+
+ $this->assertSame(EXIT_SUCCESS, $exitCode);
+ $this->assertStringContainsString('2 rows.', $io->getOutput());
+ }
+}
+```
+
## Without MockInputOutput
### Testing CLI Output
diff --git a/docs/testing/feature.md b/docs/testing/feature.md
index d88b0d83474f..fc2a4a723d76 100644
--- a/docs/testing/feature.md
+++ b/docs/testing/feature.md
@@ -40,7 +40,7 @@ class FooTest extends CIUnitTestCase
Essentially, feature tests simply allow you to call an endpoint on your application and get the results back. To do this, you use the `call()` method.
-1. The first parameter is the HTTP method to use (most frequently either `GET` or `POST`).
+1. The first parameter is the uppercase HTTP method to use (most frequently either `GET` or `POST`).
2. The second parameter is the URI path on your site to test.
@@ -54,7 +54,7 @@ Essentially, feature tests simply allow you to call an endpoint on your applicat
$result = $this->call('GET', '/');
// Submit a form
-$result = $this->call('post', 'contact', [
+$result = $this->call('POST', 'contact', [
'name' => 'Fred Flintstone',
'email' => 'flintyfred@example.com',
]);
@@ -71,6 +71,7 @@ $this->put($path, $params);
$this->patch($path, $params);
$this->delete($path, $params);
$this->options($path, $params);
+$this->query($path, $params);
```
### Setting Different Routes
@@ -85,7 +86,7 @@ $routes = [
$result = $this->withRoutes($routes)->get('users');
```
-Each of the "routes" is a 3-element array containing the HTTP verb (or "add" for all), the URI to match, and the routing destination.
+Each of the "routes" is a 3-element array containing the uppercase HTTP verb (or "add" for all), the URI to match, and the routing destination.
### Setting Session Values
@@ -117,6 +118,19 @@ $headers = [
$result = $this->withHeaders($headers)->post('users');
```
+### Testing File Uploads
+
+Use `withFiles()` to attach uploaded files to the next feature request. Create each file with `CodeIgniter\Test\Mock\MockUploadedFile`, passing an existing local file path and the name that the client would send:
+
+```php
+use CodeIgniter\Test\Mock\MockUploadedFile;
+
+$file = new MockUploadedFile($path, 'photo.jpg', 'image/jpeg');
+$result = $this->withFiles(['photo' => $file])->post('photos', ['title' => 'Portrait']);
+```
+
+The route can access the file through `$request->getFile('photo')` and use upload validation, `move()`, or `store()`. `withFiles()` sets the request's `Content-Type` to `multipart/form-data` and clears the attached files after the request. Moving or storing the file moves the local source file, so create a new file object for each upload request.
+
### Bypassing Events
Events are handy to use in your application, but can be problematic during testing, especially events that are used to send out emails. You can tell the system to skip any event handling with the `skipEvents()` method:
@@ -131,6 +145,8 @@ You can set the format of your request's body using the `withBodyFormat()` metho
This will take the parameters passed into `call()`, `post()`, `get()`... and assign them to the body of the request in the given format.
+For example, use `withBodyFormat('json')->query()` to test a QUERY request with a JSON body.
+
This will also set the `Content-Type` header for your request accordingly.
```php
diff --git a/docs/testing/overview.md b/docs/testing/overview.md
index cb8a143f72d9..e157363073ae 100644
--- a/docs/testing/overview.md
+++ b/docs/testing/overview.md
@@ -282,6 +282,19 @@ $this->assertCloseEnoughString(11 * 60, $timer->getElapsedTime('longjohn'));
The above test will allow the actual time to be either 660 or 661 seconds.
+##### assertSameSql($expected, $actual, $message = '')
+
+Asserts that two SQL strings are the same, ignoring newlines in the actual SQL:
+
+```php
+where('id', 1)->getCompiledSelect();
+
+$this->assertSameSql($expected, $actual);
+```
+
#### Accessing Protected/Private Properties
When testing, you can use the following setter and getter methods to access protected and private methods and properties in the classes that you are testing.