From 9fc6f3b09e6f1b2743a54c23defd38add79a4bf8 Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 28 Mar 2026 09:36:00 +0100 Subject: [PATCH 01/57] Document console UX improvements from cakephp/cakephp#19303 --- docs/en/appendices/5-4-migration-guide.md | 20 ++++++++++++ docs/en/console-commands/commands.md | 38 +++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index f8db436ca7..077fbdb6e9 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -16,6 +16,20 @@ bin/cake upgrade rector --rules cakephp54 ## Behavior Changes +### Console + +Running `bin/cake` without providing a command name no longer displays the +"No command provided" error message. Instead, the `help` command is shown +directly. + +The `help` command is now hidden from command listings (via +`CommandHiddenInterface`). It remains accessible by running `bin/cake help` or +`bin/cake help `. + +The CakePHP version header in help output is now only shown when the CakePHP +version can be determined. When used outside a CakePHP application (where the +version is reported as `unknown`), the header is omitted. + ### I18n `Number::parseFloat()` now returns `null` instead of `0.0` when parsing @@ -33,6 +47,12 @@ explicitly set `'strategy' => 'select'` when defining associations. ## New Features +### Console + +- Added `ConsoleHelpHeaderProviderInterface` to allow host applications to + provide a custom header in console help output. + See [Customizing the Help Header](../console-commands/commands#customizing-the-help-header). + ### Controller - Added `#[RequestToDto]` attribute for automatic mapping of request data to diff --git a/docs/en/console-commands/commands.md b/docs/en/console-commands/commands.md index ec3ab845a1..42af9eb7c3 100644 --- a/docs/en/console-commands/commands.md +++ b/docs/en/console-commands/commands.md @@ -358,6 +358,44 @@ public function console(CommandCollection $commands): CommandCollection `CommandCollection::replace()` was added. ::: +## Customizing the Help Header + +By default, `bin/cake help` displays a CakePHP version header at the top of +command listings. When the CakePHP version cannot be determined (e.g. when the +console package is used outside a CakePHP application), the header is omitted +automatically. + +You can replace the default header with your own by implementing +`Cake\Core\ConsoleHelpHeaderProviderInterface` on the application class passed +to `CommandRunner`: + +```php +MyApp: 1.4.0 (env: prod)'; + } +} +``` + +When this interface is implemented, `CommandRunner` passes the return value of +`getConsoleHelpHeader()` to `HelpCommand`, replacing the default CakePHP header. +Console markup tags such as `` and `` are supported in the +returned string. + +::: info Added in version 5.4.0 +`ConsoleHelpHeaderProviderInterface` was added. +::: + ## Tree Output Helper The `TreeHelper` outputs an array as a tree structure. This is useful for From ea55ea9fb8ed76c55a26afcda098637b6170c74c Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Fri, 3 Apr 2026 16:44:06 +0200 Subject: [PATCH 02/57] Initial attempt reusable workflow (#8268) * Initial attempt reusable workflow * Use proper tools-ref (during tests) * Target 5.x branch --- .github/.markdownlint-cli2.jsonc | 83 --- .github/check-links.cjs | 497 ------------------ .github/check-toc-links.cjs | 131 ----- .github/cspell.json | 112 ---- .../no-space-after-fence.cjs | 35 -- .github/workflows/docs-validation.yml | 115 +--- docs/ja/plugins.md | 6 + docs/ja/views.md | 2 + 8 files changed, 20 insertions(+), 961 deletions(-) delete mode 100644 .github/.markdownlint-cli2.jsonc delete mode 100755 .github/check-links.cjs delete mode 100644 .github/check-toc-links.cjs delete mode 100644 .github/cspell.json delete mode 100644 .github/markdownlint-rules/no-space-after-fence.cjs diff --git a/.github/.markdownlint-cli2.jsonc b/.github/.markdownlint-cli2.jsonc deleted file mode 100644 index 823ca5e162..0000000000 --- a/.github/.markdownlint-cli2.jsonc +++ /dev/null @@ -1,83 +0,0 @@ -{ - "config": { - "default": true, - "heading-increment": true, - "no-hard-tabs": true, - "no-multiple-blanks": true, - "line-length": false, - "commands-show-output": true, - "blanks-around-headings": true, - "heading-start-left": true, - "no-duplicate-heading": false, - "single-h1": false, - "no-trailing-punctuation": false, - "no-blanks-blockquote": false, - "list-marker-space": true, - "blanks-around-fences": true, - "blanks-around-lists": true, - "no-inline-html": { - "allowed_elements": [ - "Badge", - "div", - "span", - "br", - "style", - "details", - "summary", - "table", - "thead", - "tbody", - "tr", - "th", - "td", - "img", - "a", - "svg", - "path", - "figure", - "p", - "colgroup", - "col", - "strong", - "sup", - "section", - "hr", - "ol", - "ul", - "li", - "em", - "code" - ] - }, - "no-bare-urls": true, - "fenced-code-language": true, - "first-line-heading": false, - "code-block-style": false, - "code-fence-style": { - "style": "backtick" - }, - "emphasis-style": { - "style": "asterisk" - }, - "strong-style": { - "style": "asterisk" - }, - "spaces-after-emphasis-marker": true, - "spaces-after-code-fence-info": true, - "spaces-inside-emphasis-markers": true, - "spaces-inside-code-span-elements": true, - "single-trailing-newline": true, - "link-fragments": false, - "table-pipe-style": "leading_and_trailing", - "table-column-count": false, - "table-column-style": false, - "descriptive-link-text": false, - "no-emphasis-as-heading": false - }, - "customRules": [ - "./markdownlint-rules/no-space-after-fence.cjs" - ], - "ignores": [ - "node_modules/**" - ] -} diff --git a/.github/check-links.cjs b/.github/check-links.cjs deleted file mode 100755 index 464386b3a3..0000000000 --- a/.github/check-links.cjs +++ /dev/null @@ -1,497 +0,0 @@ -#!/usr/bin/env node - -/** - * Internal Markdown Link Checker - * - * Validates internal markdown links in documentation files. - * Only checks relative links - ignores external URLs. - * - * Usage: - * node bin/check-links.js ... - * node bin/check-links.js --baseline "docs/subfolder/*.md" - * node bin/check-links.js --generate-baseline "docs/subfolder/*.md" - */ - -const fs = require("fs"); -const path = require("path"); - -const BASELINE_FILE = ".github/linkchecker-baseline.json"; - -/** - * Simple glob implementation using Node.js built-ins - */ -function globSync(pattern, options = {}) { - const results = []; - - // Handle simple patterns like "docs/en/**/*.md" - if (pattern.includes("**")) { - const [basePath, ...rest] = pattern.split("**"); - const suffix = rest.join("**").replace(/^\//, ""); // Remove leading slash - const baseDir = basePath.replace(/\/$/, "") || "."; - - function traverse(dir) { - try { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - - if (entry.isDirectory()) { - traverse(fullPath); - } else if (entry.isFile() && matchPattern(entry.name, suffix)) { - results.push(options.absolute ? path.resolve(fullPath) : fullPath); - } - } - } catch (err) { - // Ignore permission errors - } - } - - traverse(baseDir); - } else { - // Simple wildcard pattern like "docs/en/*.md" - const dir = path.dirname(pattern); - const filePattern = path.basename(pattern); - - if (fs.existsSync(dir)) { - const entries = fs.readdirSync(dir); - for (const entry of entries) { - const fullPath = path.join(dir, entry); - if ( - fs.statSync(fullPath).isFile() && - matchPattern(entry, filePattern) - ) { - results.push(options.absolute ? path.resolve(fullPath) : fullPath); - } - } - } - } - - return results; -} - -function matchPattern(filename, pattern) { - const regex = pattern - .replace(/\./g, "\\.") - .replace(/\*/g, ".*") - .replace(/\?/g, "."); - return ( - new RegExp(`^${regex}$`).test(filename) || new RegExp(regex).test(filename) - ); -} - -class LinkChecker { - constructor(options = {}) { - this.errors = []; - this.checked = new Set(); - this.headingCache = new Map(); - this.generateBaseline = options.generateBaseline || false; - this.baselinePath = options.baselinePath || null; - this.baseline = this.baselinePath ? this.loadBaseline() : []; - } - - /** - * Load baseline configuration - */ - loadBaseline() { - if (!fs.existsSync(this.baselinePath)) { - console.warn(`Warning: Baseline file not found: ${this.baselinePath}`); - return []; - } - - try { - const content = fs.readFileSync(this.baselinePath, "utf8"); - return JSON.parse(content); - } catch (err) { - console.warn(`Warning: Could not load baseline file: ${err.message}`); - return []; - } - } - - /** - * Save baseline configuration - */ - saveBaseline() { - const baselineData = this.errors.map((error) => ({ - file: error.file, - link: error.link, - message: error.message, - })); - - const outputPath = this.baselinePath || BASELINE_FILE; - const dir = path.dirname(outputPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - - fs.writeFileSync( - outputPath, - JSON.stringify(baselineData, null, 2) + "\n", - "utf8", - ); - } - - /** - * Check if an error matches baseline - */ - isInBaseline(error) { - return this.baseline.some( - (baselineError) => - baselineError.file === error.file && baselineError.link === error.link, - ); - } - - /** - * Main entry point - check files matching patterns - */ - async checkFiles(patterns) { - const files = await this.resolveFiles(patterns); - - if (files.length === 0) { - console.error("No files found matching patterns"); - return false; - } - - console.log(`Checking ${files.length} file(s)...\n`); - - for (const file of files) { - this.checkFile(file); - } - - return this.reportResults(); - } - - /** - * Resolve glob patterns to actual file paths - */ - async resolveFiles(patterns) { - const fileSet = new Set(); - - for (const pattern of patterns) { - // Check if it's a direct file path - if (fs.existsSync(pattern) && fs.statSync(pattern).isFile()) { - fileSet.add(path.resolve(pattern)); - } else { - // Treat as glob pattern - const matches = globSync(pattern, { - absolute: true, - }); - matches.forEach((f) => fileSet.add(f)); - } - } - - return Array.from(fileSet).sort(); - } - - /** - * Check all links in a single file - */ - checkFile(filePath) { - if (this.checked.has(filePath)) { - return; - } - this.checked.add(filePath); - - const content = fs.readFileSync(filePath, "utf8"); - const links = this.extractLinks(content); - const sourceDir = path.dirname(filePath); - - for (const link of links) { - if (this.isExternalLink(link.url)) { - continue; - } - - const { targetPath, anchor } = this.parseLink(link.url, sourceDir); - - // Check file exists - if (!fs.existsSync(targetPath)) { - this.addError( - filePath, - link, - `File not found: ${path.relative(process.cwd(), targetPath)}`, - ); - continue; - } - - // Check anchor if present - if (anchor) { - const headings = this.getHeadings(targetPath); - const anchorId = this.slugify(anchor); - - if (!headings.includes(anchorId)) { - this.addError( - filePath, - link, - `Anchor '#${anchor}' not found in ${path.relative(process.cwd(), targetPath)}`, - ); - } - } - } - } - - /** - * Extract all markdown links from content - */ - extractLinks(content) { - const links = []; - - // Remove code blocks first (fenced with ```), then inline code (single backticks) - let contentWithoutCodeBlocks = content.replace(/```[\s\S]*?```/g, ""); - // For inline code, only match within a single line (no newlines) - contentWithoutCodeBlocks = contentWithoutCodeBlocks.replace( - /`[^`\n]+`/g, - "", - ); - - // Match [text](url) or [text](url "title") - const linkRegex = /\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; - let match; - - while ((match = linkRegex.exec(contentWithoutCodeBlocks)) !== null) { - const url = match[2]; - - // Skip bare URLs wrapped in angle brackets (e.g., ) - if (url.startsWith("<") && url.endsWith(">")) { - continue; - } - - links.push({ - text: match[1], - url: url, - raw: match[0], - }); - } - - return links; - } - - /** - * Check if a URL is external - */ - isExternalLink(url) { - // Skip HTTP(S), mailto, IRC, anchor-only, protocol-relative, and absolute paths (VitePress public dir) - return /^(https?:\/\/|mailto:|irc:\/\/|#|\/\/|\/[^.])/i.test(url); - } - - /** - * Parse link URL into target path and anchor - */ - parseLink(url, sourceDir) { - const [pathPart, anchor] = url.split("#"); - - // Handle anchor-only links (same file) - if (!pathPart) { - return { - targetPath: path.join(sourceDir, path.basename(sourceDir) + ".md"), - anchor, - }; - } - - let targetPath = path.resolve(sourceDir, pathPart); - - // VitePress automatically adds .md extension if not present - // Match this behavior by always trying .md first for extensionless links - if (!path.extname(targetPath)) { - const mdPath = targetPath + ".md"; - if (fs.existsSync(mdPath)) { - targetPath = mdPath; - } else if ( - fs.existsSync(targetPath) && - fs.statSync(targetPath).isDirectory() - ) { - // If it's a directory, look for index.md - targetPath = path.join(targetPath, "index.md"); - } else { - // Default to .md extension (VitePress behavior) - targetPath = mdPath; - } - } else if ( - fs.existsSync(targetPath) && - fs.statSync(targetPath).isDirectory() - ) { - // If path is directory, look for index.md - targetPath = path.join(targetPath, "index.md"); - } - - return { targetPath, anchor }; - } - - /** - * Extract headings from a markdown file - */ - getHeadings(filePath) { - if (this.headingCache.has(filePath)) { - return this.headingCache.get(filePath); - } - - const content = fs.readFileSync(filePath, "utf8"); - const headings = []; - - // Match ATX headings: # Heading - const headingRegex = /^#{1,6}\s+(.+)$/gm; - let match; - - while ((match = headingRegex.exec(content)) !== null) { - const heading = match[1] - .replace(/\{#([^}]+)\}$/g, "") // Remove {#custom-id} but keep the ID - .replace(/\[[^\]]+\]\([^)]+\)/g, "") // Remove links - .replace(/`([^`]+)`/g, "$1") // Remove code formatting - .trim(); - - // Check if there's a custom ID in the original match - const customIdMatch = match[1].match(/\{#([^}]+)\}$/); - if (customIdMatch) { - headings.push(customIdMatch[1]); // Add the custom ID - } - - headings.push(this.slugify(heading)); - } - - // Also extract HTML anchor IDs: - const htmlAnchorRegex = / !this.isInBaseline(error)); - - if (newErrors.length === 0 && this.errors.length === 0) { - console.log("✓ All internal links are valid!\n"); - return true; - } - - if (newErrors.length === 0 && this.errors.length > 0) { - console.log( - `✓ No new broken links (${this.errors.length} known issue(s) in baseline)\n`, - ); - return true; - } - - console.error(`✗ Found ${newErrors.length} new broken link(s):\n`); - - for (const error of newErrors) { - console.error(` ${error.file}`); - console.error(` Link: [${error.text}](${error.link})`); - console.error(` Error: ${error.message}`); - console.error(""); - } - - if (this.errors.length > newErrors.length) { - const baselineCount = this.errors.length - newErrors.length; - console.error( - ` (${baselineCount} known issue(s) ignored from baseline)\n`, - ); - } - - return false; - } -} - -// Main execution -async function main() { - const args = process.argv.slice(2); - - // Parse arguments - let generateBaseline = false; - let baselinePath = null; - const patterns = []; - - for (let i = 0; i < args.length; i++) { - if (args[i] === "--generate-baseline") { - generateBaseline = true; - } else if (args[i] === "--baseline") { - if (i + 1 < args.length) { - baselinePath = args[++i]; - } else { - console.error("Error: --baseline requires a path argument"); - process.exit(1); - } - } else { - patterns.push(args[i]); - } - } - - if (patterns.length === 0) { - console.error( - "Usage: node bin/check-links.js [--baseline ] [--generate-baseline] ...", - ); - console.error(""); - console.error("Examples:"); - console.error(" node bin/check-links.js docs/en/installation.md"); - console.error(' node bin/check-links.js "docs/**/*.md"'); - console.error(" node bin/check-links.js docs/en/*.md docs/ja/*.md"); - console.error(""); - console.error("With baseline:"); - console.error( - ' node bin/check-links.js --baseline .github/linkchecker-baseline.json "docs/**/*.md"', - ); - console.error(""); - console.error("Generate baseline:"); - console.error( - ' node bin/check-links.js --generate-baseline "docs/**/*.md"', - ); - console.error( - ' node bin/check-links.js --generate-baseline --baseline custom-baseline.json "docs/**/*.md"', - ); - process.exit(1); - } - - const checker = new LinkChecker({ generateBaseline, baselinePath }); - const success = await checker.checkFiles(patterns); - - process.exit(success ? 0 : 1); -} - -// Run if executed directly -if (require.main === module) { - main().catch((error) => { - console.error("Error:", error.message); - process.exit(1); - }); -} - -module.exports = LinkChecker; diff --git a/.github/check-toc-links.cjs b/.github/check-toc-links.cjs deleted file mode 100644 index 44a899a145..0000000000 --- a/.github/check-toc-links.cjs +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env node - -/** - * TOC Link Validator - * - * Validates that all links in toc_*.json files point to existing markdown files. - * - * Usage: - * node bin/check-toc-links.js - */ - -const fs = require("fs"); -const path = require("path"); - -/** - * Recursively extract all links from TOC items - */ -function extractLinks(items, links = []) { - for (const item of items) { - if (item.link && !item.link.startsWith("http")) { - links.push(item.link); - } - if (item.items) { - extractLinks(item.items, links); - } - } - return links; -} - -/** - * Check if a TOC link resolves to an existing file - */ -function checkLink(link, docsDir, lang) { - // TOC links may include language prefix: "/ja/quickstart" or just "/quickstart" - // Strip the language prefix if present - let relativePath = link.startsWith("/") ? link.slice(1) : link; - - // Remove language prefix if link starts with it (e.g., "ja/quickstart" -> "quickstart") - const langPrefix = lang + "/"; - if (relativePath.startsWith(langPrefix)) { - relativePath = relativePath.slice(langPrefix.length); - } - - const filePath = path.join(docsDir, relativePath + ".md"); - - return fs.existsSync(filePath); -} - -/** - * Extract language code from TOC filename - * e.g., "toc_en.json" -> "en" - */ -function getLangFromTocFile(tocFile) { - const match = tocFile.match(/toc_(\w+)\.json$/); - return match ? match[1] : null; -} - -/** - * Main validation function - */ -function validateTocFiles() { - const tocFiles = fs.readdirSync(".vitepress").filter((f) => f.match(/^toc_.*\.json$/)).map((f) => `.vitepress/${f}`); - - if (tocFiles.length === 0) { - console.error("No toc_*.json files found"); - process.exit(1); - } - - let hasErrors = false; - - for (const tocFile of tocFiles) { - const lang = getLangFromTocFile(tocFile); - if (!lang) { - console.error(`Could not extract language from ${tocFile}`); - continue; - } - - const docsDir = path.join("docs", lang); - if (!fs.existsSync(docsDir)) { - console.error(`Docs directory not found: ${docsDir}`); - hasErrors = true; - continue; - } - - console.log(`Checking ${tocFile} against ${docsDir}/...`); - - const content = fs.readFileSync(tocFile, "utf8"); - const toc = JSON.parse(content); - - // TOC structure has keys like "/" with arrays of items - const allLinks = []; - for (const key of Object.keys(toc)) { - extractLinks(toc[key], allLinks); - } - - const missingFiles = []; - for (const link of allLinks) { - if (!checkLink(link, docsDir, lang)) { - missingFiles.push(link); - } - } - - if (missingFiles.length > 0) { - hasErrors = true; - console.error(`\n✗ ${tocFile}: ${missingFiles.length} broken link(s)\n`); - for (const link of missingFiles) { - // Calculate expected path (strip lang prefix if present) - let relativePath = link.startsWith("/") ? link.slice(1) : link; - const langPrefix = lang + "/"; - if (relativePath.startsWith(langPrefix)) { - relativePath = relativePath.slice(langPrefix.length); - } - const expectedPath = path.join(docsDir, relativePath + ".md"); - console.error(` ${link}`); - console.error(` Expected: ${expectedPath}`); - } - console.error(""); - } else { - console.log(`✓ ${tocFile}: ${allLinks.length} link(s) valid\n`); - } - } - - if (hasErrors) { - console.error("TOC validation failed"); - process.exit(1); - } - - console.log("✓ All TOC links are valid!"); -} - -validateTocFiles(); diff --git a/.github/cspell.json b/.github/cspell.json deleted file mode 100644 index 76796b8214..0000000000 --- a/.github/cspell.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "version": "0.2", - "language": "en", - "caseSensitive": false, - "words": [ - "CakePHP", - "VitePress", - "php", - "datetime", - "varchar", - "postgresql", - "mysql", - "sqlite", - "webroot", - "csrf", - "xss", - "cakephp", - "bake", - "phinx", - "hasher", - "middlewares", - "namespaced", - "phpunit", - "composable", - "autowiring", - "stringable", - "arrayable", - "timestampable", - "paginator", - "Enqueue", - "dequeue", - "nullable", - "accessor", - "mutator", - "minphpversion", - "phpversion", - "XAMPP", - "WAMP", - "MAMP", - "controllername", - "actionname", - "Datamapper", - "webservices", - "nginx", - "apache", - "httpd", - "lighttpd", - "mbstring", - "simplexml", - "Laragon", - "composer", - "phar", - "paginate", - "startup", - "endfor", - "endwhile", - "sidebar", - "blocks", - "rewrite", - "before", - "called", - "sites", - "example", - "Classes", - "Redirect", - "Layout", - "full", - "olde", - "olle" - ], - "ignoreRegExpList": [ - "\\$[a-zA-Z_][a-zA-Z0-9_]*", - "[a-zA-Z]+::[a-zA-Z]+", - "<[^>]+>", - "`[^`]+`", - "```[\\s\\S]*?```", - "\\b[A-Z][a-z]+(?:[A-Z][a-z]+)+\\b", - "\\b[a-z]+_[a-z_]+\\b", - "\\bhttps?:\\/\\/[^\\s]+", - "\\[[a-z]\\][a-z]+", - "\\b[A-Z][a-z]+\\b", - "\\b(true|false|null|while|for|if|else)\\b" - ], - "languageSettings": [ - { - "languageId": "*", - "locale": "en", - "dictionaries": ["en", "softwareTerms", "php"] - }, - { - "languageId": "*", - "locale": "ja", - "allowCompoundWords": true - } - ], - "overrides": [ - { - "filename": "**/docs/ja/**/*.md", - "language": "ja", - "ignoreRegExpList": [ - "[\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Han}]+" - ] - } - ], - "ignorePaths": [ - "node_modules/**", - ".temp/**", - "dist/**", - "**/*.min.js", - "**/*.lock" - ] -} diff --git a/.github/markdownlint-rules/no-space-after-fence.cjs b/.github/markdownlint-rules/no-space-after-fence.cjs deleted file mode 100644 index b6b0f479a7..0000000000 --- a/.github/markdownlint-rules/no-space-after-fence.cjs +++ /dev/null @@ -1,35 +0,0 @@ -"use strict"; - -module.exports = { - names: ["no-space-after-fence"], - description: "Disallow spaces between a fence and the info string.", - tags: ["code", "fences", "whitespace"], - function: function noSpaceAfterFence(params, onError) { - const lines = params.lines || []; - - (params.tokens || []).forEach((token) => { - if (token.type !== "fence") { - return; - } - - if (!token.markup || token.markup[0] !== "`") { - return; - } - - if (!token.map || token.map.length === 0) { - return; - } - - const lineNumber = token.map[0] + 1; - const line = lines[lineNumber - 1] || ""; - - if (/^\s*`{3,}[ \t]+\S/.test(line)) { - onError({ - lineNumber, - detail: "Remove the space between the fence and the info string.", - context: line.trim() - }); - } - }); - } -}; diff --git a/.github/workflows/docs-validation.yml b/.github/workflows/docs-validation.yml index 031c9dcefc..9f629f2caa 100644 --- a/.github/workflows/docs-validation.yml +++ b/.github/workflows/docs-validation.yml @@ -19,106 +19,15 @@ on: - 'config.js' jobs: - js-lint: - name: Validate JavaScript Files - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Validate config.js syntax - run: node --check .vitepress/config.js - - json-lint: - name: Validate JSON Files - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Validate JSON syntax - run: | - for file in .vitepress/toc_*.json; do - if ! jq empty "$file" 2>/dev/null; then - echo "Invalid JSON: $file" - exit 1 - fi - echo "✅ Valid: $file" - done - - toc-link-check: - name: Validate TOC Links - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Check TOC links exist - run: node .github/check-toc-links.cjs - - markdown-lint: - name: Lint Markdown - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Lint markdown files - uses: DavidAnson/markdownlint-cli2-action@v23 - with: - config: './.github/.markdownlint-cli2.jsonc' - globs: 'docs/**/*.md' - - spell-check: - name: Spell Check - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Check spelling - uses: streetsidesoftware/cspell-action@v8 - with: - files: 'docs/**/*.md' - config: '.github/cspell.json' - incremental_files_only: true - - link-check: - name: Check Internal Links - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Get changed markdown files - id: changed-files - run: | - if [ "${{ github.event_name }}" == "pull_request" ]; then - # Get files changed in PR - FILES=$(git diff --name-only --diff-filter=d origin/${{ github.base_ref }}...HEAD | grep '\.md$' || true) - if [ -z "$FILES" ]; then - echo "No markdown files changed" - echo "files=" >> $GITHUB_OUTPUT - else - # Convert to space-separated list for script - echo "files=$(echo $FILES | tr '\n' ' ')" >> $GITHUB_OUTPUT - echo "Checking files:" - echo "$FILES" - fi - else - # For push events, check all files using glob pattern - echo 'files="docs/**/*.md"' >> $GITHUB_OUTPUT - echo 'Checking all files: docs/**/*.md' - fi - - - name: Check internal links - if: steps.changed-files.outputs.files != '' - run: node .github/check-links.cjs --baseline .github/linkchecker-baseline.json ${{ steps.changed-files.outputs.files }} + validate: + uses: cakephp/.github/.github/workflows/docs-validation.yml@5.x + with: + docs-path: 'docs' + enable-config-js-check: true + enable-json-lint: true + enable-toc-check: true + enable-spell-check: true + enable-markdown-lint: true + enable-link-check: true + linkchecker-baseline: '.github/linkchecker-baseline.json' + tools-ref: '5.x' diff --git a/docs/ja/plugins.md b/docs/ja/plugins.md index 6fbc2b7a68..02abbee0cd 100644 --- a/docs/ja/plugins.md +++ b/docs/ja/plugins.md @@ -43,6 +43,8 @@ php composer.phar require cakephp/debug_kit 'ContactManager' という名前のフォルダーを作り、この下にプラグインの src, tests といった フォルダーを作ります。 + + ### プラグインクラスを手動で自動読み込み `composer` や `bake` を使ってプラグインをインストールするなら、 @@ -125,6 +127,8 @@ bin/cake plugin load ContactManager これは、アプリケーションの bootstrap メソッドを更新、 または `$this->addPlugin('ContactManager');` を bootstrap に書き込みます。 + + ## プラグインフックの設定 プラグインは、プラグインがアプリケーションの適切な部分に自分自身を注入できるようにする @@ -279,6 +283,8 @@ bin/cake bake controller --plugin ContactManager Contacts php composer.phar dumpautoload ``` + + ## Plugin オブジェクト Plugin オブジェクトを使用すると、プラグイン作成者は設定ロジックを定義し、 diff --git a/docs/ja/views.md b/docs/ja/views.md index 74d86dd4d8..927320cae5 100644 --- a/docs/ja/views.md +++ b/docs/ja/views.md @@ -244,6 +244,8 @@ $this->extend('/Common/index'); $list = $this->blocks(); ``` + + ## ビューブロックの使用 ビューブロックはビュー/レイアウトの中のどこかで定義されるであろう、スロットやブロックを From 0eb9c048c80c8baaff670af2833b394a0dedd888 Mon Sep 17 00:00:00 2001 From: Mark Story Date: Fri, 3 Apr 2026 23:44:37 -0400 Subject: [PATCH 03/57] Remove redundant spaces --- docs/en/appendices/5-4-migration-guide.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index f4b7902c14..1f84e5c1d0 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -16,7 +16,6 @@ bin/cake upgrade rector --rules cakephp54 ## Behavior Changes - ### Commands - `BaseCommand::initialize()` is now being triggered **AFTER** arguments and options have been parsed. @@ -64,7 +63,6 @@ version is reported as `unknown`), the header is omitted. ## New Features - ### Core - `PluginConfig::getInstalledPlugins()` was added to retrieve a list of all installed plugins From 3fee6dd6061a692577db987d388248b85aaea21b Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sat, 4 Apr 2026 12:04:33 +0200 Subject: [PATCH 04/57] re-organize plugins to navbar (#8269) * re-organize plugins to navbar * Also adjust JA toc --- .vitepress/config.js | 28 +++++++++++++++++++++++++++- .vitepress/toc_en.json | 21 +-------------------- .vitepress/toc_ja.json | 19 +------------------ 3 files changed, 29 insertions(+), 39 deletions(-) diff --git a/.vitepress/config.js b/.vitepress/config.js index 7df86ec9bd..aca016c2dc 100644 --- a/.vitepress/config.js +++ b/.vitepress/config.js @@ -19,6 +19,31 @@ const versions = { ], }; +const plugins = { + text: "Plugins", + items: [ + { + text: "Core Plugins", + items: [ + { text: "Authentication", link: "https://book.cakephp.org/authentication/", target: "_self" }, + { text: "Authorization", link: "https://book.cakephp.org/authorization/", target: "_self" }, + { text: "Bake", link: "https://book.cakephp.org/bake/", target: "_self" }, + { text: "Chronos", link: "https://book.cakephp.org/chronos/", target: "_self" }, + { text: "Debug Kit", link: "https://book.cakephp.org/debugkit/", target: "_self" }, + { text: "Elasticsearch", link: "https://book.cakephp.org/elasticsearch/", target: "_self" }, + { text: "Migrations", link: "https://book.cakephp.org/migrations/", target: "_self" }, + { text: "Phinx", link: "https://book.cakephp.org/phinx/", target: "_self" }, + ], + }, + { + text: "Community", + items: [ + { text: "Community Plugins", link: "https://plugins.cakephp.org/", target: "_self" }, + ], + }, + ], +}; + const substitutions = { '|phpversion|': { value: '8.5', format: 'bold' }, '|minphpversion|': { value: '8.2', format: 'italic' }, @@ -53,7 +78,7 @@ export default { nav: [ { text: "Guide", link: "/intro" }, { text: "API", link: "https://api.cakephp.org/" }, - { text: "Documentation", link: "/" }, + { ...plugins }, { ...versions }, ], }, @@ -70,6 +95,7 @@ export default { { text: "ガイド", link: "/ja/intro" }, { text: "API", link: "https://api.cakephp.org/" }, { text: "ドキュメント", link: "/ja/" }, + { ...plugins }, { ...versions }, ], sidebar: toc_ja, diff --git a/.vitepress/toc_en.json b/.vitepress/toc_en.json index 864068e3bf..0369fb574b 100644 --- a/.vitepress/toc_en.json +++ b/.vitepress/toc_en.json @@ -306,31 +306,12 @@ "text": "Dependency Injection", "link": "/development/dependency-injection" }, + { "text": "plugins", "link": "/plugins" }, { "text": "Errors", "link": "/development/errors" }, { "text": "REST", "link": "/development/rest" }, { "text": "Testing", "link": "/development/testing" } ] }, - { - "text": "Plugins", - "collapsed": true, - "items": [ - { "text": "Creating Plugins", "link": "/plugins" }, - { - "text": "Core Plugins", - "items": [ - { "text": "Authentication", "link": "https://book.cakephp.org/authentication/" }, - { "text": "Authorization", "link": "https://book.cakephp.org/authorization/" }, - { "text": "Bake", "link": "https://book.cakephp.org/bake/" }, - { "text": "Chronos", "link": "https://book.cakephp.org/chronos/" }, - { "text": "Debug Kit", "link": "https://book.cakephp.org/debugkit/" }, - { "text": "Elasticsearch", "link": "https://book.cakephp.org/elasticsearch/" }, - { "text": "Migrations", "link": "https://book.cakephp.org/migrations/" }, - { "text": "Phinx", "link": "https://book.cakephp.org/phinx/" } - ] - } - ] - }, { "text": "Security", "collapsed": true, diff --git a/.vitepress/toc_ja.json b/.vitepress/toc_ja.json index ca3bb0bd70..b51f05c459 100644 --- a/.vitepress/toc_ja.json +++ b/.vitepress/toc_ja.json @@ -394,29 +394,12 @@ "text": "依存性注入", "link": "/ja/development/dependency-injection" }, + { "text": "プラグイン", "link": "/ja/plugins" }, { "text": "エラー", "link": "/ja/development/errors" }, { "text": "REST", "link": "/ja/development/rest" }, { "text": "テスト", "link": "/ja/development/testing" } ] }, - { - "text": "プラグイン", - "collapsed": true, - "items": [ - { "text": "プラグインの作成", "link": "/ja/plugins" }, - { - "text": "コアプラグイン", - "items": [ - { "text": "Bake", "link": "https://book.cakephp.org/bake/2/en/index.html" }, - { "text": "Chronos", "link": "https://book.cakephp.org/chronos/" }, - { "text": "Debug Kit", "link": "https://book.cakephp.org/debugkit/" }, - { "text": "Elasticsearch", "link": "https://book.cakephp.org/elasticsearch/" }, - { "text": "マイグレーション", "link": "https://book.cakephp.org/migrations/" }, - { "text": "Phinx", "link": "https://book.cakephp.org/phinx/" } - ] - } - ] - }, { "text": "セキュリティ", "collapsed": true, From b401e84498f433318a4a54dca75437daeb52841d Mon Sep 17 00:00:00 2001 From: Jasper Smet Date: Sun, 5 Apr 2026 10:12:55 +0200 Subject: [PATCH 05/57] Add missing queue plugin to plugins list --- .vitepress/config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/.vitepress/config.js b/.vitepress/config.js index aca016c2dc..5526b99ffc 100644 --- a/.vitepress/config.js +++ b/.vitepress/config.js @@ -33,6 +33,7 @@ const plugins = { { text: "Elasticsearch", link: "https://book.cakephp.org/elasticsearch/", target: "_self" }, { text: "Migrations", link: "https://book.cakephp.org/migrations/", target: "_self" }, { text: "Phinx", link: "https://book.cakephp.org/phinx/", target: "_self" }, + { text: "Queue", link: "https://book.cakephp.org/queue/", target: "_self" }, ], }, { From fc0937c9c04902e20dcd5ae1c5bae0b5943e5005 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:53:49 +0200 Subject: [PATCH 06/57] Bump vite from 7.3.1 to 7.3.2 (#8270) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.1 to 7.3.2. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 7.3.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 45 +++------------------------------------------ 1 file changed, 3 insertions(+), 42 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5e7966fe6f..f100e25964 100644 --- a/package-lock.json +++ b/package-lock.json @@ -615,9 +615,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -631,9 +628,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -647,9 +641,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -663,9 +654,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -679,9 +667,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -695,9 +680,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -711,9 +693,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -727,9 +706,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -743,9 +719,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -759,9 +732,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -775,9 +745,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -791,9 +758,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -807,9 +771,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1984,9 +1945,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", "dependencies": { "esbuild": "^0.27.0", From a2440090f27fb305c2b4762284ce674504a8b102 Mon Sep 17 00:00:00 2001 From: mscherer Date: Thu, 9 Apr 2026 14:29:46 +0200 Subject: [PATCH 07/57] Fix lowercase word. --- .vitepress/toc_en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vitepress/toc_en.json b/.vitepress/toc_en.json index 0369fb574b..aa57629d90 100644 --- a/.vitepress/toc_en.json +++ b/.vitepress/toc_en.json @@ -306,7 +306,7 @@ "text": "Dependency Injection", "link": "/development/dependency-injection" }, - { "text": "plugins", "link": "/plugins" }, + { "text": "Plugins", "link": "/plugins" }, { "text": "Errors", "link": "/development/errors" }, { "text": "REST", "link": "/development/rest" }, { "text": "Testing", "link": "/development/testing" } From da38b39e2407bc09dc3a5b41467bf78f87bd760a Mon Sep 17 00:00:00 2001 From: Masatoshi Ogiwara Date: Fri, 10 Apr 2026 16:22:50 +0900 Subject: [PATCH 08/57] fix: Infragistics::slug() changed into Text::slug() (#8275) --- docs/ja/orm/behaviors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ja/orm/behaviors.md b/docs/ja/orm/behaviors.md index ecde56b2bb..fd902bf4e6 100644 --- a/docs/ja/orm/behaviors.md +++ b/docs/ja/orm/behaviors.md @@ -24,7 +24,7 @@ ## ビヘイビアーの生成 次の例では、非常に単純な `SluggableBehavior` を作成します。 このビヘイビアーは、 -別のフィールドに基づいて、 `Infragistics::slug()` の結果を slug フィールドに +別のフィールドに基づいて、 `Text::slug()` の結果を slug フィールドに 取り込むことを可能にします。 ビヘイビアーを作成する前に、ビヘイビアーの規約を理解する必要があります。 From a2d9159c5c8b8cc7355e9b2a2ee2f39f370ea5f8 Mon Sep 17 00:00:00 2001 From: Erwane Breton Date: Fri, 10 Apr 2026 10:53:37 +0200 Subject: [PATCH 09/57] Precision about OptionParser 'separator' (#8274) * Precision about OptionParser 'separator' Clarified requirement for 'separator' option to require 'multiple' to be true. * Fix grammar in option-parsers.md documentation --------- Co-authored-by: Mark Scherer --- docs/en/console-commands/option-parsers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/console-commands/option-parsers.md b/docs/en/console-commands/option-parsers.md index 87d84a4e01..63a618ceb3 100644 --- a/docs/en/console-commands/option-parsers.md +++ b/docs/en/console-commands/option-parsers.md @@ -158,7 +158,7 @@ of the option: - `multiple` - The option can be provided multiple times. The parsed option will be an array of values when this option is enabled. - `separator` - A character sequence that the option value is split into an - array with. + array with. Requires `multiple` set to `true`. - `choices` - An array of valid choices for this option. If left empty all values are valid. An exception will be raised when parse() encounters an invalid value. From 7f1f8018798b6debe203e448408261289604c022 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 11 Apr 2026 13:08:26 +0200 Subject: [PATCH 10/57] Document PostgreSQL index access method reflection (#8277) --- docs/en/appendices/5-4-migration-guide.md | 6 ++++++ docs/en/orm/schema-system.md | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index 1f84e5c1d0..d350869cf4 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -98,6 +98,12 @@ version is reported as `unknown`), the header is omitted. See [Query Builder](../orm/query-builder#advanced-conditions). - Added `inOrNull()` and `notInOrNull()` methods for combining `IN` conditions with `IS NULL`. - Added `isDistinctFrom()` and `isNotDistinctFrom()` methods for null-safe comparisons. +- Added PostgreSQL index access method reflection. Non-btree indexes (`gin`, + `gist`, `spgist`, `brin`, `hash`) are now reflected with an `accessMethod` + field and regenerated with the correct `USING` clause. The `Index` class + provides constants (`Index::GIN`, `Index::GIST`, `Index::SPGIST`, + `Index::BRIN`, `Index::HASH`) for these access methods. + See [Reading Indexes and Constraints](../orm/schema-system#reading-indexes-and-constraints). ### I18n diff --git a/docs/en/orm/schema-system.md b/docs/en/orm/schema-system.md index f62f0cb4a7..4b89e16140 100644 --- a/docs/en/orm/schema-system.md +++ b/docs/en/orm/schema-system.md @@ -182,6 +182,28 @@ $indexes = $schema->indexes() $index = $schema->index('author_id_idx') ``` +#### PostgreSQL Index Access Methods + +::: info Added in version 5.4.0 +::: + +When reflecting indexes on PostgreSQL, non-btree indexes include an +`accessMethod` field identifying the underlying index type (`gin`, `gist`, +`spgist`, `brin`, `hash`). Schemas generated from these reflections will emit +the appropriate `USING` clause when recreating the index. + +```php +$schema->addIndex('articles_tags_idx', [ + 'columns' => ['tags'], + 'type' => 'index', + 'accessMethod' => 'gin', +]); +``` + +The `Cake\Database\Schema\Index` class exposes constants for the supported +access methods: `Index::GIN`, `Index::GIST`, `Index::SPGIST`, `Index::BRIN`, +and `Index::HASH`. Btree indexes (the default) omit the `accessMethod` field. + ### Adding Table Options Some drivers (primarily MySQL) support and require additional table metadata. In From f367ec92a4a856744f9f6f4353e5d5ceded54d3d Mon Sep 17 00:00:00 2001 From: mscherer Date: Sat, 11 Apr 2026 20:07:01 +0200 Subject: [PATCH 11/57] Document FunctionsBuilder::stringAgg() Adds documentation for the new portable stringAgg() method, covering per-driver translation and supported ordering. --- docs/en/appendices/5-4-migration-guide.md | 3 ++ docs/en/orm/query-builder.md | 37 +++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index d350869cf4..5cce72220e 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -98,6 +98,9 @@ version is reported as `unknown`), the header is omitted. See [Query Builder](../orm/query-builder#advanced-conditions). - Added `inOrNull()` and `notInOrNull()` methods for combining `IN` conditions with `IS NULL`. - Added `isDistinctFrom()` and `isNotDistinctFrom()` methods for null-safe comparisons. +- Added `FunctionsBuilder::stringAgg()` for portable string aggregation. + Translates to `STRING_AGG` or `GROUP_CONCAT` per driver. + See [Query Builder](../orm/query-builder#string-aggregation). - Added PostgreSQL index access method reflection. Non-btree indexes (`gin`, `gist`, `spgist`, `brin`, `hash`) are now reflected with an `accessMethod` field and regenerated with the correct `USING` clause. The `Index` class diff --git a/docs/en/orm/query-builder.md b/docs/en/orm/query-builder.md index bb5215dd9f..027ec5e279 100644 --- a/docs/en/orm/query-builder.md +++ b/docs/en/orm/query-builder.md @@ -360,6 +360,11 @@ Calculate the max of a column. `Assumes arguments are literal values.` `count()` Calculate the count. `Assumes arguments are literal values.` +`stringAgg()` +Aggregate string values using a separator. Translates to `STRING_AGG()`, +`GROUP_CONCAT()`, or `LISTAGG()` depending on the database driver. +`Assumes the first argument is a literal value.` + `cast()` Convert a field or expression from one data type to another. @@ -471,6 +476,38 @@ FROM articles; > [!NOTE] > Use `func()` to pass untrusted user data to any SQL function. +#### String Aggregation + +The `stringAgg()` method provides a portable way to aggregate string values +using a separator. It translates to the appropriate native SQL function for +each driver (`STRING_AGG()` on PostgreSQL and SQL Server, `GROUP_CONCAT()` on +MySQL, and `STRING_AGG()` or `GROUP_CONCAT()` on MariaDB/SQLite depending on +version): + +```php +$query = $articles->find(); +$query->select([ + 'category_id', + 'titles' => $query->func()->stringAgg('title', ', '), +]) +->groupBy('category_id'); +``` + +You can optionally specify an ordering for the aggregated values via the +third argument: + +```php +$query->func()->stringAgg('title', ', ', ['title' => 'ASC']); +``` + +`STRING_AGG` with aggregate-local ordering is supported on PostgreSQL, +SQL Server, MariaDB 10.5+ and SQLite 3.44+. MySQL translates the call to +`GROUP_CONCAT` in all cases. + +::: info Added in version 5.4.0 +`FunctionsBuilder::stringAgg()` was added. +::: + ### Ordering Results To apply ordering, you can use the `orderBy()` method: From c124ae636d4db6e86a4681b145de90a62af3e11a Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sun, 12 Apr 2026 14:44:23 +0200 Subject: [PATCH 12/57] improve ORM strategy changes (#8280) --- docs/en/appendices/5-4-migration-guide.md | 1 + docs/en/orm/associations.md | 14 ++++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index d350869cf4..df06dbca21 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -44,6 +44,7 @@ version is reported as `unknown`), the header is omitted. - The default eager loading strategy for `HasMany` and `BelongsToMany` associations has changed from `select` to `subquery`. If you need the previous behavior, explicitly set `'strategy' => 'select'` when defining associations. + See [Associations](../orm/associations#has-many-associations) for more details. ### Controller diff --git a/docs/en/orm/associations.md b/docs/en/orm/associations.md index 8d4a36a97b..3b2bd30e5d 100644 --- a/docs/en/orm/associations.md +++ b/docs/en/orm/associations.md @@ -462,9 +462,12 @@ Possible keys for hasMany association arrays include: - **propertyName**: The property name that should be filled with data from the associated table into the source table results. By default, this is the underscored & plural name of the association so `comments` in our example. -- **strategy**: Defines the query strategy to use. Defaults to 'subquery'. The - other valid value is 'select', which uses the `IN` list of parent keys +- **strategy**: Defines the query strategy to use. Defaults to `subquery`. The + other valid value is `select`, which uses the `IN` list of parent keys directly instead of a subquery. +::: tip New default strategy in version 5.4+ +The default strategy has changed from `select` to `subquery` in order to improve performance when the number of parent keys is large. +::: - **saveStrategy**: Either `append` or `replace`. Defaults to `append`. When `append` the current records are appended to any records in the database. When `replace` associated records not in the current set will be removed. If the foreign key is a nullable @@ -616,9 +619,12 @@ Possible keys for belongsToMany association arrays include: - **propertyName**: The property name that should be filled with data from the associated table into the source table results. By default, this is the underscored & plural name of the association, so `tags` in our example. -- **strategy**: Defines the query strategy to use. Defaults to 'subquery'. The - other valid value is 'select', which uses the `IN` list of parent keys +- **strategy**: Defines the query strategy to use. Defaults to `subquery`. The + other valid value is `select`, which uses the `IN` list of parent keys directly instead of a subquery. +::: tip New default strategy in version 5.4+ +The default strategy has changed from `select` to `subquery` in order to improve performance when the number of parent keys is large. +::: - **saveStrategy**: Either `append` or `replace`. Defaults to `replace`. Indicates the mode to be used for saving associated entities. The former will only create new links between both side of the relation and the latter will From 14db7d67803895c9036e014f08c2b5b1988ee4e6 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 12 Apr 2026 14:45:24 +0200 Subject: [PATCH 13/57] Document EXCEPT and EXCEPT ALL set operations (#8278) Adds documentation for the new except() and exceptAll() methods on SelectQuery, covering supported platforms for EXCEPT ALL. --- docs/en/appendices/5-4-migration-guide.md | 4 +++ docs/en/orm/query-builder.md | 33 +++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index df06dbca21..feeae150be 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -99,6 +99,10 @@ version is reported as `unknown`), the header is omitted. See [Query Builder](../orm/query-builder#advanced-conditions). - Added `inOrNull()` and `notInOrNull()` methods for combining `IN` conditions with `IS NULL`. - Added `isDistinctFrom()` and `isNotDistinctFrom()` methods for null-safe comparisons. +- Added `except()` and `exceptAll()` methods on `SelectQuery` for `EXCEPT` + and `EXCEPT ALL` set operations. `EXCEPT ALL` is supported on PostgreSQL + and recent MySQL/MariaDB versions; it is not supported on SQLite or SQL Server. + See [Query Builder](../orm/query-builder#except). - Added PostgreSQL index access method reflection. Non-btree indexes (`gin`, `gist`, `spgist`, `brin`, `hash`) are now reflected with an `accessMethod` field and regenerated with the correct `USING` clause. The `Index` class diff --git a/docs/en/orm/query-builder.md b/docs/en/orm/query-builder.md index bb5215dd9f..2cb9f5004d 100644 --- a/docs/en/orm/query-builder.md +++ b/docs/en/orm/query-builder.md @@ -2200,6 +2200,39 @@ $unpublished->intersectAll($inReview); `intersect()` and `intersectAll()` were added. ::: +### Except + +Except operations allow you to return rows from one query that do not appear +in another query. Except queries are created by composing one or more select +queries together: + +```php +$allArticles = $articles->find(); + +$published = $articles->find() + ->where(['published' => true]); + +$allArticles->except($published); +``` + +You can create `EXCEPT ALL` queries using the `exceptAll()` method: + +```php +$allArticles = $articles->find(); + +$published = $articles->find() + ->where(['published' => true]); + +$allArticles->exceptAll($published); +``` + +`EXCEPT ALL` is supported on PostgreSQL and recent MySQL/MariaDB versions. +It is not supported on SQLite or SQL Server. + +::: info Added in version 5.4.0 +`except()` and `exceptAll()` were added. +::: + ### Subqueries Subqueries enable you to compose queries together and build conditions and From 4fdd91798ef7c6483186396ba3dd907027ba991f Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Tue, 14 Apr 2026 13:15:09 -0400 Subject: [PATCH 14/57] Document Connection::afterCommit() and updated afterSaveCommit/afterDeleteCommit behavior --- docs/en/orm/database-basics.md | 47 ++++++++++++++++++++++++++++++++++ docs/en/orm/table-objects.md | 27 ++++++++++++++++--- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/docs/en/orm/database-basics.md b/docs/en/orm/database-basics.md index f3599b6999..1914ffb337 100644 --- a/docs/en/orm/database-basics.md +++ b/docs/en/orm/database-basics.md @@ -1107,6 +1107,53 @@ do the following: - If the closure returns `false`, a rollback will be issued. - If the closure executes successfully, the transaction will be committed. +### afterCommit + +`method` Cake\\Database\\Connection::**afterCommit**(callable $callback): void + +You can register callbacks to run after the outermost transaction commits using +``afterCommit()``. This is useful for deferring side effects like sending +emails, dispatching jobs, or invalidating caches until you know the data has +been persisted: + +```php +$connection->begin(); +$connection->execute('UPDATE articles SET published = ? WHERE id = ?', [true, 2]); +$connection->afterCommit(function () { + // Send notification email — only runs if the transaction commits. + $this->mailer->send('article-published'); +}); +$connection->commit(); // Callback fires here. +``` + +Callbacks are discarded if the transaction is rolled back. When nested +transactions are in use, callbacks registered at any depth are deferred until +the outermost transaction commits: + +```php +$connection->begin(); +$connection->afterCommit(function () { + // This fires after the outermost commit. +}); + +$connection->begin(); // Nested (savepoint) +$connection->afterCommit(function () { + // Also deferred to outermost commit. +}); +$connection->commit(); // Releases savepoint — callbacks don't fire yet. + +$connection->commit(); // Outermost commit — both callbacks fire now. +``` + +If ``afterCommit()`` is called when no transaction is active, the callback +executes immediately. This matches the semantics of the ORM's +``Model.afterSaveCommit`` event, which also fires immediately for non-atomic +saves. + +::: info Added in version 5.4.0 +`Connection::afterCommit()` was added. +::: + ## Interacting with Statements When using the lower level database API, you will often encounter statement diff --git a/docs/en/orm/table-objects.md b/docs/en/orm/table-objects.md index 50aa8b7d21..f3d8f95c90 100644 --- a/docs/en/orm/table-objects.md +++ b/docs/en/orm/table-objects.md @@ -320,8 +320,18 @@ The `Model.afterSave` event is fired after an entity is saved. The `Model.afterSaveCommit` event is fired after the transaction in which the save operation is wrapped has been committed. It's also triggered for non atomic saves where database operations are implicitly committed. The event is triggered -only for the primary table on which `save()` is directly called. The event is -not triggered if a transaction is started before calling save. +only for the primary table on which `save()` is directly called. + +When `save()` is called inside an outer transaction (e.g. one started with +`Connection::begin()`), the event is deferred until the outermost transaction +commits. If the outer transaction is rolled back, the event is discarded. This +ensures the event only fires after data has been persisted to the database. + +::: info Changed in version 5.4.0 +Previously, this event was not triggered if a transaction was started before +calling `save()`. It is now deferred and fires after the outermost +transaction commits. +::: ### beforeDelete @@ -342,10 +352,19 @@ The `Model.afterDelete` event is fired after an entity has been deleted. `method` Cake\\ORM\\Table::**afterDeleteCommit**(EventInterface $event, EntityInterface $entity, ArrayObject $options): void The `Model.afterDeleteCommit` event is fired after the transaction in which the -delete operation is wrapped has been is committed. It's also triggered for non +delete operation is wrapped has been committed. It's also triggered for non atomic deletes where database operations are implicitly committed. The event is triggered only for the primary table on which `delete()` is directly called. -The event is not triggered if a transaction is started before calling delete. + +When `delete()` is called inside an outer transaction, the event is deferred +until the outermost transaction commits. If the outer transaction is rolled +back, the event is discarded. + +::: info Changed in version 5.4.0 +Previously, this event was not triggered if a transaction was started before +calling `delete()`. It is now deferred and fires after the outermost +transaction commits. +::: ### Stopping Table Events From e86bd9d62b30875b23fa972bb6afb30dab8ac2fb Mon Sep 17 00:00:00 2001 From: Jamison Bryant Date: Tue, 14 Apr 2026 13:21:35 -0400 Subject: [PATCH 15/57] Add afterCommit entries to 5.4 migration guide --- docs/en/appendices/5-4-migration-guide.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index feeae150be..612a08e498 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -45,6 +45,11 @@ version is reported as `unknown`), the header is omitted. has changed from `select` to `subquery`. If you need the previous behavior, explicitly set `'strategy' => 'select'` when defining associations. See [Associations](../orm/associations#has-many-associations) for more details. +- `Model.afterSaveCommit` and `Model.afterDeleteCommit` events are now fired + when `save()` or `delete()` is called inside an outer transaction. Previously, + these events were silently suppressed. They are now deferred until the + outermost transaction commits, and discarded on rollback. + See [Table Objects](../orm/table-objects#aftersavecommit) for more details. ### Controller @@ -99,6 +104,9 @@ version is reported as `unknown`), the header is omitted. See [Query Builder](../orm/query-builder#advanced-conditions). - Added `inOrNull()` and `notInOrNull()` methods for combining `IN` conditions with `IS NULL`. - Added `isDistinctFrom()` and `isNotDistinctFrom()` methods for null-safe comparisons. +- Added `Connection::afterCommit()` to register callbacks that run after the + outermost transaction commits. Callbacks are discarded on rollback. + See [Database Basics](../orm/database-basics#aftercommit) for more details. - Added `except()` and `exceptAll()` methods on `SelectQuery` for `EXCEPT` and `EXCEPT ALL` set operations. `EXCEPT ALL` is supported on PostgreSQL and recent MySQL/MariaDB versions; it is not supported on SQLite or SQL Server. From 0746926551c33bddee57dda7272daf66449830f7 Mon Sep 17 00:00:00 2001 From: Mallik Hassan Date: Wed, 15 Apr 2026 00:11:07 -0300 Subject: [PATCH 16/57] Doc for Text::mask() --- docs/en/core-libraries/text.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/en/core-libraries/text.md b/docs/en/core-libraries/text.md index 9aa39ac0d9..1a3a73f55a 100644 --- a/docs/en/core-libraries/text.md +++ b/docs/en/core-libraries/text.md @@ -453,4 +453,32 @@ Output: red, orange, yellow, green, blue, indigo and violet +## Text Masking + +### Text::mask() + +`method` Cake\\Utility\\Text::**mask**(string $string, int $offset, ?int $length = null, string $maskCharacter = '*'): string + +Masks a portion of a string with a repeated character. + +Replaces characters starting at `$offset` for `$length` characters with `$maskCharacter`. +If `$length` is `null`, masking continues to the end of the string. +Negative offsets are supported and are calculated from the end of the string. + +```php +$creditCardNumber = '4909090909091234'; + +// Called as TextHelper +echo $this->Text->mask($creditCardNumber, 0, 12, '*'); + +// Called as Text +use Cake\Utility\Text; + +echo Text::mask($creditCardNumber, 0, 12, '*'); +``` + +Output: + + ************1234 + From 9ad965d8a493254d43a72bf0e7c615fcb4625246 Mon Sep 17 00:00:00 2001 From: Mallik Hassan Date: Wed, 15 Apr 2026 08:39:01 -0300 Subject: [PATCH 17/57] Added Text::mask() in migration guide --- docs/en/appendices/5-4-migration-guide.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index feeae150be..cdcc4617a3 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -71,6 +71,7 @@ version is reported as `unknown`), the header is omitted. - A backwards compatible Container implementation has been added to the core. You can opt-in to use it instead of the current `league/container` implementation by setting `App.container` to `cake` inside your `config/app.php`. See [Dependency Injection Container](../development/dependency-injection) for more details. +- `Text::mask()` was added to mask a portion of a string with a repeated character. See [Text Masking](../core-libraries/text.md#text-masking) for more details. ### Commands From 20b911961d86833af02b02c520e834763c244ca0 Mon Sep 17 00:00:00 2001 From: Mallik Hassan Date: Wed, 15 Apr 2026 09:09:09 -0300 Subject: [PATCH 18/57] Moved function doc under utility --- docs/en/appendices/5-4-migration-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index cdcc4617a3..cfc699ec01 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -71,7 +71,6 @@ version is reported as `unknown`), the header is omitted. - A backwards compatible Container implementation has been added to the core. You can opt-in to use it instead of the current `league/container` implementation by setting `App.container` to `cake` inside your `config/app.php`. See [Dependency Injection Container](../development/dependency-injection) for more details. -- `Text::mask()` was added to mask a portion of a string with a repeated character. See [Text Masking](../core-libraries/text.md#text-masking) for more details. ### Commands @@ -135,6 +134,7 @@ version is reported as `unknown`), the header is omitted. path manipulation. See [Filesystem Utilities](../core-libraries/filesystem.md). - `Security::encrypt()` can now be configured to use longer keys with separate encryption and authentication keys that are derived from the provided key. You can set `Security.encryptWithRawKey` to enable this behavior. See [here](https://github.com/cakephp/cakephp/pull/19325) for more details. +- Added `Text::mask()` method which masks a portion of a string with a repeated character. See [Text Masking](../core-libraries/text.md#text-masking) for more details. ### Collection From 83a104f5ecbce2e831b1073b692aa4e8fef70382 Mon Sep 17 00:00:00 2001 From: mscherer Date: Thu, 16 Apr 2026 05:45:39 +0200 Subject: [PATCH 19/57] Change code-formatted notes to italic emphasis --- docs/en/orm/query-builder.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/en/orm/query-builder.md b/docs/en/orm/query-builder.md index 77e211aa60..6b9a93d9af 100644 --- a/docs/en/orm/query-builder.md +++ b/docs/en/orm/query-builder.md @@ -346,36 +346,36 @@ You can access existing wrappers for several SQL functions through `SelectQuery: Generate a random value between 0 and 1 via SQL. `sum()` -Calculate a sum. `Assumes arguments are literal values.` +Calculate a sum. *Assumes arguments are literal values.* `avg()` -Calculate an average. `Assumes arguments are literal values.` +Calculate an average. *Assumes arguments are literal values.* `min()` -Calculate the min of a column. `Assumes arguments are literal values.` +Calculate the min of a column. *Assumes arguments are literal values.* `max()` -Calculate the max of a column. `Assumes arguments are literal values.` +Calculate the max of a column. *Assumes arguments are literal values.* `count()` -Calculate the count. `Assumes arguments are literal values.` +Calculate the count. *Assumes arguments are literal values.* `stringAgg()` Aggregate string values using a separator. Translates to `STRING_AGG()`, `GROUP_CONCAT()`, or `LISTAGG()` depending on the database driver. -`Assumes the first argument is a literal value.` +*Assumes the first argument is a literal value.* `cast()` Convert a field or expression from one data type to another. `concat()` -Concatenate two values together. `Assumes arguments are bound parameters.` +Concatenate two values together. *Assumes arguments are bound parameters.* `coalesce()` -Coalesce values. `Assumes arguments are bound parameters.` +Coalesce values. *Assumes arguments are bound parameters.* `dateDiff()` -Get the difference between two dates/times. `Assumes arguments are bound parameters.` +Get the difference between two dates/times. *Assumes arguments are bound parameters.* `now()` Defaults to returning date and time, but accepts 'time' or 'date' to return only From 5e4e8eaab4fd3cc7ce51a21b7525e3a6587d4399 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Thu, 16 Apr 2026 07:24:42 +0200 Subject: [PATCH 20/57] Change code-formatted notes to italic emphasis (#8283) --- docs/en/orm/query-builder.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/en/orm/query-builder.md b/docs/en/orm/query-builder.md index 2ea7e22f1d..cb7327a59c 100644 --- a/docs/en/orm/query-builder.md +++ b/docs/en/orm/query-builder.md @@ -346,31 +346,31 @@ You can access existing wrappers for several SQL functions through `SelectQuery: Generate a random value between 0 and 1 via SQL. `sum()` -Calculate a sum. `Assumes arguments are literal values.` +Calculate a sum. *Assumes arguments are literal values.* `avg()` -Calculate an average. `Assumes arguments are literal values.` +Calculate an average. *Assumes arguments are literal values.* `min()` -Calculate the min of a column. `Assumes arguments are literal values.` +Calculate the min of a column. *Assumes arguments are literal values.* `max()` -Calculate the max of a column. `Assumes arguments are literal values.` +Calculate the max of a column. *Assumes arguments are literal values.* `count()` -Calculate the count. `Assumes arguments are literal values.` +Calculate the count. *Assumes arguments are literal values.* `cast()` Convert a field or expression from one data type to another. `concat()` -Concatenate two values together. `Assumes arguments are bound parameters.` +Concatenate two values together. *Assumes arguments are bound parameters.* `coalesce()` -Coalesce values. `Assumes arguments are bound parameters.` +Coalesce values. *Assumes arguments are bound parameters.* `dateDiff()` -Get the difference between two dates/times. `Assumes arguments are bound parameters.` +Get the difference between two dates/times. *Assumes arguments are bound parameters.* `now()` Defaults to returning date and time, but accepts 'time' or 'date' to return only From 5d904e0571b0a608619c66341c76f792668c07dc Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Thu, 16 Apr 2026 18:42:39 +0200 Subject: [PATCH 21/57] add docs for new mockModel method in TestCase (#8276) --- docs/en/appendices/5-4-migration-guide.md | 24 +++++++++++++---------- docs/en/development/testing.md | 17 ++++++++++++++++ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index 52b57bb56f..15663ed314 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -77,6 +77,12 @@ version is reported as `unknown`), the header is omitted. `league/container` implementation by setting `App.container` to `cake` inside your `config/app.php`. See [Dependency Injection Container](../development/dependency-injection) for more details. +### Collection + +- Added [`keys()`](../core-libraries/collections#keys) and [`values()`](../core-libraries/collections#values) methods for extracting keys or re-indexing values. +- Added [`implode()`](../core-libraries/collections#implode) method to concatenate elements into a string. +- Added [`when()`](../core-libraries/collections#when) and [`unless()`](../core-libraries/collections#unless) methods for conditional method chaining. + ### Commands - You can use `$this->io` and `$this->args` inside your commands to access input/output and argument objects @@ -118,6 +124,12 @@ version is reported as `unknown`), the header is omitted. `Index::BRIN`, `Index::HASH`) for these access methods. See [Reading Indexes and Constraints](../orm/schema-system#reading-indexes-and-constraints). +### Http + +- Added PSR-13 Link implementation with `Cake\Http\Link\Link` and `Cake\Http\Link\LinkProvider` + classes for hypermedia link support. Links added to responses are automatically emitted + as HTTP `Link` headers. See [Hypermedia Links](../controllers/request-response#hypermedia-links). + ### I18n - `Number::toReadableSize()` now uses decimal units (KB = 1000 bytes) by default. @@ -129,11 +141,9 @@ version is reported as `unknown`), the header is omitted. nested array format matching `contain()` syntax. See [Converting Request Data into Entities](../orm/saving-data#converting-request-data-into-entities). -### Http +### Testsuite -- Added PSR-13 Link implementation with `Cake\Http\Link\Link` and `Cake\Http\Link\LinkProvider` - classes for hypermedia link support. Links added to responses are automatically emitted - as HTTP `Link` headers. See [Hypermedia Links](../controllers/request-response#hypermedia-links). +- `TestCase::mockModel()` has been added to allow mocking of model classes in tests using Mockery mocks. ### Utility @@ -144,12 +154,6 @@ version is reported as `unknown`), the header is omitted. You can set `Security.encryptWithRawKey` to enable this behavior. See [here](https://github.com/cakephp/cakephp/pull/19325) for more details. - Added `Text::mask()` method which masks a portion of a string with a repeated character. See [Text Masking](../core-libraries/text.md#text-masking) for more details. -### Collection - -- Added [`keys()`](../core-libraries/collections#keys) and [`values()`](../core-libraries/collections#values) methods for extracting keys or re-indexing values. -- Added [`implode()`](../core-libraries/collections#implode) method to concatenate elements into a string. -- Added [`when()`](../core-libraries/collections#when) and [`unless()`](../core-libraries/collections#unless) methods for conditional method chaining. - ### View - Added `{{inputId}}` template variable to `inputContainer` and `error` templates diff --git a/docs/en/development/testing.md b/docs/en/development/testing.md index 4acde5c865..ada6eb4817 100644 --- a/docs/en/development/testing.md +++ b/docs/en/development/testing.md @@ -1043,6 +1043,23 @@ In your `tearDown()` method be sure to remove the mock with: $this->getTableLocator()->clear(); ``` +::: info Added in version 5.4 +::: + +If you prefer Mockery mocks you can use `mockModel()` instead of `getMockForModel()`. + +```php +public function testSendingEmails(): void +{ + $model = $this->mockModel('EmailVerification'); + $mock->shouldReceive('send') + ->once() + ->andReturn(true); + + $model->verifyEmail('test@example.com'); +} +``` + ## Controller Integration Testing From e27597055e8c757e53916a7dd001e28a59c49d3a Mon Sep 17 00:00:00 2001 From: Mark Story Date: Sat, 18 Apr 2026 13:11:32 -0400 Subject: [PATCH 22/57] Improve formatting of connection options (#8284) Right now the names of the options aren't easy to scan. Having a list with code formatting on the option names improves readability. --- docs/en/orm/database-basics.md | 70 ++++++++++++---------------------- 1 file changed, 24 insertions(+), 46 deletions(-) diff --git a/docs/en/orm/database-basics.md b/docs/en/orm/database-basics.md index f3599b6999..8a12b7d0eb 100644 --- a/docs/en/orm/database-basics.md +++ b/docs/en/orm/database-basics.md @@ -188,99 +188,77 @@ use a non-default connection, see [Configuring Table Connections](../orm/table-o There are a number of keys supported in database configuration. A full list is as follows: -className -: The fully namespaced class name of the class that represents the connection to a database server. +- `className`: The fully namespaced class name of the class that represents the connection to a database server. This class is responsible for loading the database driver, providing SQL transaction mechanisms and preparing SQL statements among other things. -driver -: The class name of the driver used to implement all specificities for +- `driver`: The class name of the driver used to implement all specificities for a database engine. This can either be a short classname using `plugin syntax`, a fully namespaced name, or a constructed driver instance. Examples of short classnames are Mysql, Sqlite, Postgres, and Sqlserver. -persistent -: Whether or not to use a persistent connection to the database. This option +- `persistent`: Whether or not to use a persistent connection to the database. This option is not supported by SqlServer. An exception is thrown if you attempt to set `persistent` to `true` with SqlServer. -host -: The database server's hostname (or IP address). +- `host`: The database server's hostname (or IP address). -username -: The username for the account. +- `username`: The username for the account. -password -: The password for the account. +- `password`: The password for the account. -database -: The name of the database for this connection to use. Avoid using `.` in +- `database`: The name of the database for this connection to use. Avoid using `.` in your database name. Because of how it complicates identifier quoting CakePHP does not support `.` in database names. The path to your SQLite database should be an absolute path (for example, `ROOT . DS . 'my_app.db'`) to avoid incorrect paths caused by relative paths. -port (*optional*) -: The TCP port or Unix socket used to connect to the server. +- `port`: The TCP port or Unix socket used to connect to the server + (*optional*). -encoding -: Indicates the character set to use when sending SQL statements to +- `encoding`: Indicates the character set to use when sending SQL statements to the server. This defaults to the database's default encoding for all databases other than DB2. -timezone -: Server timezone to set. +- `timezone`: Server timezone to set. -schema -: Used in PostgreSQL database setups to specify which schema to use. +- `schema`: Used in PostgreSQL database setups to specify which schema to use. -unix_socket -: Used by drivers that support it to connect via Unix socket files. If you are +- `unix_socket`: Used by drivers that support it to connect via Unix socket files. If you are using PostgreSQL and want to use Unix sockets, leave the host key blank. -ssl_key -: The file path to the SSL key file. (Only supported by MySQL). +- `ssl_key`: The file path to the SSL key file. (Only supported by MySQL). -ssl_cert -: The file path to the SSL certificate file. (Only supported by MySQL). +- `ssl_cert`: The file path to the SSL certificate file. (Only supported by MySQL). -ssl_ca -: The file path to the SSL certificate authority. (Only supported by MySQL). +- `ssl_ca`: The file path to the SSL certificate authority. (Only supported by MySQL). -init -: A list of queries that should be sent to the database server as +- `init`: A list of queries that should be sent to the database server as when the connection is created. -log -: Set to `true` to enable query logging. When enabled queries will be logged +- `log`: Set to `true` to enable query logging. When enabled queries will be logged at a `debug` level with the `queriesLog` scope. -quoteIdentifiers -: Set to `true` if you are using reserved words or special characters in +- `quoteIdentifiers`: Set to `true` if you are using reserved words or special characters in your table or column names. Enabling this setting will result in queries built using the [Query Builder](../orm/query-builder) having identifiers quoted when creating SQL. It should be noted that this decreases performance because each query needs to be traversed and manipulated before being executed. -flags -: An associative array of PDO constants that should be passed to the +- `flags`: An associative array of PDO constants that should be passed to the underlying PDO instance. See the PDO documentation for the flags supported by the driver you are using. -cacheMetadata -: Either boolean `true`, or a string containing the cache configuration to +- `cacheMetadata`: Either boolean `true`, or a string containing the cache configuration to store meta data in. Having metadata caching disabled by setting it to `false` is not advised and can result in very poor performance. See the [Database Metadata Cache](#database-metadata-cache) section for more information. -mask -: Set the permissions on the generated database file. (Only supported by SQLite) +- `mask`: Set the permissions on the generated database file. (Only supported by SQLite) -cache -: The `cache` flag to send to SQLite. +- `cache`: The `cache` flag to send to SQLite. -mode -: The `mode` flag value to send to SQLite. +- `mode`: The `mode` flag value to send to SQLite. ### SqlServer Entra Authentication From 271cab9f06e32323adac4cfff01b22ca84f052ad Mon Sep 17 00:00:00 2001 From: ADmad Date: Mon, 20 Apr 2026 10:22:28 +0530 Subject: [PATCH 23/57] Update docs for request detectors (#8285) * Update docs for request detectors * Fix typo --- docs/en/controllers/request-response.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/en/controllers/request-response.md b/docs/en/controllers/request-response.md index bb23ea13df..6ad39d1a92 100644 --- a/docs/en/controllers/request-response.md +++ b/docs/en/controllers/request-response.md @@ -448,8 +448,7 @@ There are several built-in detectors that you can use: - `is('options')` Check to see whether the current request is OPTIONS. - `is('ajax')` Check to see whether the current request came with X-Requested-With = XMLHttpRequest. -- `is('ssl')` Check to see whether the request is via SSL. -- `is('flash')` Check to see whether the request has a User-Agent of Flash. +- `is('https')` Check to see whether the request is via HTTPS. - `is('json')` Check to see whether the request URL has 'json' extension or the `Accept` header is set to 'application/json'. - `is('xml')` Check to see whether the request URL has 'xml' extension or the `Accept` header is set to From 25b507278b06bed4aed1b212d9a67a75ecadb682 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Mon, 20 Apr 2026 14:29:06 +0200 Subject: [PATCH 24/57] fix install docs refering to 5.3.0 (#8286) --- docs/en/index.md | 6 +++--- docs/en/installation.md | 6 +++--- docs/en/tutorials-and-examples/cms/installation.md | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/en/index.md b/docs/en/index.md index a80f4f9257..45116e5c4e 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -40,7 +40,7 @@ Get a CakePHP application running in under 5 minutes: ```bash [Composer] # Create new project -composer create-project --prefer-dist cakephp/app:|cakeversion| my_app +composer create-project --prefer-dist cakephp/app:~|cakeversion| my_app # Start development server cd my_app @@ -53,14 +53,14 @@ bin/cake server # Setup with DDEV mkdir my-cakephp-app && cd my-cakephp-app ddev config --project-type=cakephp --docroot=webroot -ddev composer create --prefer-dist cakephp/app:|cakeversion| +ddev composer create --prefer-dist cakephp/app:~|cakeversion| ddev launch ``` ```bash [Docker] # Using official PHP image docker run -it --rm -v $(pwd):/app composer create-project \ - --prefer-dist cakephp/app:|cakeversion| my_app + --prefer-dist cakephp/app:~|cakeversion| my_app cd my_app docker run -it --rm -p 8765:8765 -v $(pwd):/app \ diff --git a/docs/en/installation.md b/docs/en/installation.md index e8da3cc4b3..87eb6f3d69 100644 --- a/docs/en/installation.md +++ b/docs/en/installation.md @@ -71,7 +71,7 @@ Now you can create a new CakePHP project: ```bash [Create Project] # Create a new CakePHP 5 application -composer create-project --prefer-dist cakephp/app:|cakeversion| my_app_name +composer create-project --prefer-dist cakephp/app:~|cakeversion| my_app_name # Navigate to your app cd my_app_name @@ -100,7 +100,7 @@ Perfect for local development environments: # Create and configure project mkdir my-cakephp-app && cd my-cakephp-app ddev config --project-type=cakephp --docroot=webroot -ddev composer create --prefer-dist cakephp/app:|cakeversion| +ddev composer create --prefer-dist cakephp/app:~|cakeversion| # Launch in browser ddev launch @@ -132,7 +132,7 @@ For containerized development: ```bash # Create project using Composer in Docker docker run --rm -v $(pwd):/app composer create-project \ - --prefer-dist cakephp/app:|cakeversion| my_app + --prefer-dist cakephp/app:~|cakeversion| my_app # Start PHP development server (install required extensions first) cd my_app diff --git a/docs/en/tutorials-and-examples/cms/installation.md b/docs/en/tutorials-and-examples/cms/installation.md index 97cc4ade83..29e4269e66 100644 --- a/docs/en/tutorials-and-examples/cms/installation.md +++ b/docs/en/tutorials-and-examples/cms/installation.md @@ -56,11 +56,11 @@ in the **cms** directory of the current working directory: ::: code-group ```bash [Linux/macOS] -php composer.phar create-project --prefer-dist cakephp/app:|cakeversion| cms +php composer.phar create-project --prefer-dist cakephp/app:~|cakeversion| cms ``` ```bash [Windows] -composer create-project --prefer-dist cakephp/app:|cakeversion| cms +composer create-project --prefer-dist cakephp/app:~|cakeversion| cms ``` ::: From 7e1383d69edd1b85e55384e5086fbdd57e89ad17 Mon Sep 17 00:00:00 2001 From: mscherer Date: Mon, 20 Apr 2026 15:50:38 +0200 Subject: [PATCH 25/57] Add modern elements to Structure & Conventions page Extend conventions.md with Enum, Mailer and Form class conventions so the page reflects current CakePHP 5.x application structure: - Add Enum class example (src/Model/Enum/ArticleStatus) with file path, PSR-4 row, reference table row, file tree entry and SQL column example - Add Form (src/Form/*Form.php) and Mailer (src/Mailer/*Mailer.php) to the src/ directory table and the PSR-4 naming table - Fix Enum docs anchor (#enum-type) and replace misleading created_at column example with is_processed (CakePHP defaults to created/modified) --- docs/en/intro/conventions.md | 43 +++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/docs/en/intro/conventions.md b/docs/en/intro/conventions.md index 72e9dfe121..65f9d6910e 100644 --- a/docs/en/intro/conventions.md +++ b/docs/en/intro/conventions.md @@ -53,8 +53,10 @@ The `src/` folder is where you'll do most development. Here's what goes in each | **Command** | Console commands | `*Command.php` - See [Command Objects](../console-commands/commands) | | **Console** | Installation scripts | Executed by Composer | | **Controller** | HTTP request handlers | [Controllers](../controllers), [Components](../controllers/components) | +| **Form** | Non-ORM form objects | `*Form.php` - See [Form](../core-libraries/form) | +| **Mailer** | Email sending classes | `*Mailer.php` - See [Email](../core-libraries/email) | | **Middleware** | Request/response filters | `*Middleware.php` - See [Middleware](../controllers/middleware) | -| **Model** | Data layer | [Tables](../orm/table-objects), [Entities](../orm/entities), [Behaviors](../orm/behaviors) | +| **Model** | Data layer | [Tables](../orm/table-objects), [Entities](../orm/entities), [Behaviors](../orm/behaviors), [Enums](../orm/database-basics#enum-type) | | **View** | Presentation logic | [Views](../views), [Cells](../views/cells), [Helpers](../views/helpers) | > [!NOTE] @@ -146,6 +148,18 @@ class User extends Entity } ``` +```php [✅ Enum Class] +// File: src/Model/Enum/ArticleStatus.php +namespace App\Model\Enum; + +enum ArticleStatus: string +{ + case Draft = 'draft'; + case Published = 'published'; + case Archived = 'archived'; +} +``` + ::: **Rules:** @@ -154,7 +168,7 @@ class User extends Entity - `UsersTable`, `MenuLinksTable`, `UserFavoritePagesTable` - **Entity class:** Singular, CamelCased, no suffix - `User`, `MenuLink`, `UserFavoritePage` -- **Enum class:** `{Entity}{Column}` - e.g., `UserStatus`, `OrderState` +- **Enum class:** `{Entity}{Column}` in `src/Model/Enum/` - e.g., `ArticleStatus`, `UserRole` - **Behavior class:** Ends in `Behavior` - `TimestampBehavior` ### Views & Templates @@ -227,7 +241,7 @@ CREATE TABLE tags_articles; - **Table names:** Plural, underscored - `users`, `menu_links` - **Multiple words:** Only pluralize the last word - `user_favorite_pages` (not `users_favorites_pages`) -- **Columns:** Underscored - `first_name`, `created_at` +- **Columns:** Underscored - `first_name`, `is_processed` - **Foreign keys:** `{singular_table}_id` - `user_id`, `menu_link_id` - **Junction tables:** Alphabetically sorted plurals - `articles_tags` (not `tags_articles`) @@ -282,10 +296,13 @@ All files follow **PSR-4 autoloading** - filenames must match class names exactl | Component | `MyHandyComponent` | `MyHandyComponent.php` | `src/Controller/Component/` | | Table | `OptionValuesTable` | `OptionValuesTable.php` | `src/Model/Table/` | | Entity | `OptionValue` | `OptionValue.php` | `src/Model/Entity/` | +| Enum | `ArticleStatus` | `ArticleStatus.php` | `src/Model/Enum/` | | Behavior | `EspeciallyFunkableBehavior` | `EspeciallyFunkableBehavior.php` | `src/Model/Behavior/` | | View | `SuperSimpleView` | `SuperSimpleView.php` | `src/View/` | | Helper | `BestEverHelper` | `BestEverHelper.php` | `src/View/Helper/` | | Command | `UpdateCacheCommand` | `UpdateCacheCommand.php` | `src/Command/` | +| Mailer | `UserMailer` | `UserMailer.php` | `src/Mailer/` | +| Form | `ContactForm` | `ContactForm.php` | `src/Form/` | ## Complete Example: Articles Feature @@ -299,6 +316,7 @@ CREATE TABLE articles ( user_id INT, title VARCHAR(255), body TEXT, + status VARCHAR(20), -- backed by App\Model\Enum\ArticleStatus created DATETIME, modified DATETIME ); @@ -319,8 +337,10 @@ src/ ├── Model/ │ ├── Table/ │ │ └── ArticlesTable.php → class ArticlesTable -│ └── Entity/ -│ └── Article.php → class Article +│ ├── Entity/ +│ │ └── Article.php → class Article +│ └── Enum/ +│ └── ArticleStatus.php → enum ArticleStatus templates/ └── Articles/ ├── index.php → ArticlesController::index() @@ -346,6 +366,7 @@ URL: `https://example.com/articles/view/5` | **Database Table** | `articles` | `menu_links` | Plural, underscored | | **Table Class** | `ArticlesTable` | `MenuLinksTable` | Plural, CamelCased, ends in `Table` | | **Entity Class** | `Article` | `MenuLink` | Singular, CamelCased | +| **Enum Class** | `ArticleStatus` | `MenuLinkType` | `{Entity}{Column}` in `src/Model/Enum/` | | **Controller Class** | `ArticlesController` | `MenuLinksController` | Plural, CamelCased, ends in `Controller` | | **Template Path** | `templates/Articles/` | `templates/MenuLinks/` | Matches controller name | | **Template File** | `index.php`, `add.php` | `index.php`, `add.php` | Underscored action name | @@ -359,13 +380,13 @@ URL: `https://example.com/articles/view/5` ::: details Database Convention Summary -| Convention | Description | Example | -|------------|-------------|---------| +| Convention | Description | Example | +|------------|-------------|---------------------------------------------| | **Foreign Keys** | `{singular_table}_id` for hasMany/belongsTo/hasOne | Users hasMany Articles → `articles.user_id` | -| **Multi-word FKs** | Use singular of full table name | `menu_links` table → `menu_link_id` | -| **Junction Tables** | Alphabetically sorted plurals | `articles_tags` (not `tags_articles`) | -| **Primary Keys** | Auto-increment INT or UUID | UUID auto-generated via `Text::uuid()` | -| **Column Names** | Underscored for multiple words | `first_name`, `created_at` | +| **Multi-word FKs** | Use singular of full table name | `menu_links` table → `menu_link_id` | +| **Junction Tables** | Alphabetically sorted plurals | `articles_tags` (not `tags_articles`) | +| **Primary Keys** | Auto-increment INT or UUID | UUID auto-generated via `Text::uuid()` | +| **Column Names** | Underscored for multiple words | `first_name`, `is_processed` | > [!WARNING] > If junction tables have additional data columns, create a dedicated Table and Entity class for them. From 6ee902d5fad9c7f444c6db461c1b944dfb169f9d Mon Sep 17 00:00:00 2001 From: Masatoshi Ogiwara Date: Wed, 22 Apr 2026 15:42:51 +0900 Subject: [PATCH 26/57] chore: remove redundant colon (#8288) --- docs/ja/console-commands/i18n.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ja/console-commands/i18n.md b/docs/ja/console-commands/i18n.md index d861e64a6b..45e6846aeb 100644 --- a/docs/ja/console-commands/i18n.md +++ b/docs/ja/console-commands/i18n.md @@ -15,7 +15,7 @@ POT ファイルを生成することができます。 このコマンドはアプリケーション全体から `__()` 形式の関数をスキャンし、メッセージ文字列を 抽出します。アプリケーション中のユニークな文字列は -それぞれひとつの POT ファイルの中にマージされます。 : +それぞれひとつの POT ファイルの中にマージされます。 ```text .. code-block:: console @@ -48,7 +48,7 @@ bin/cake i18n extract --plugin はずです。 それには `--paths` オプションを使用することができます。 そのオプションに抽出する絶対パスをカンマ区切りリストで -渡します。 : +渡します。 bin/cake i18n extract --paths /var/www/app/config,/var/www/app/src From 695cab0966555c748d4757d890099963fd4febaa Mon Sep 17 00:00:00 2001 From: Mallik Hassan Date: Mon, 27 Apr 2026 22:52:57 -0300 Subject: [PATCH 27/57] Doc for `Text::maskValue()` (#8290) * Doc for maskValue() function * Doc for maskValue() function * Update text.md Co-authored-by: Mark Story --------- Co-authored-by: Mark Story --- docs/en/appendices/5-4-migration-guide.md | 1 + docs/en/core-libraries/text.md | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index 3ab9a579c8..a7440c2660 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -156,6 +156,7 @@ version is reported as `unknown`), the header is omitted. - `Security::encrypt()` can now be configured to use longer keys with separate encryption and authentication keys that are derived from the provided key. You can set `Security.encryptWithRawKey` to enable this behavior. See [here](https://github.com/cakephp/cakephp/pull/19325) for more details. - Added `Text::mask()` method which masks a portion of a string with a repeated character. See [Text Masking](../core-libraries/text.md#text-masking) for more details. +- Added `Text::maskValue()` method which masks all occurrences of given substrings within a string using a repeated character. See [Text Masking](../core-libraries/text.md#text-masking) for more details. ### View diff --git a/docs/en/core-libraries/text.md b/docs/en/core-libraries/text.md index 1a3a73f55a..ff8703372e 100644 --- a/docs/en/core-libraries/text.md +++ b/docs/en/core-libraries/text.md @@ -481,4 +481,24 @@ Output: ************1234 +### Text::maskValue() + +`method` Cake\\Utility\\Text::**maskValue**(string $string, array $needles, string $maskCharacter = '*'): string + +Masks all occurrences of given substring(s) within a string using a repeated character. +Each occurrence of the provided substring(s) will be replaced by a sequence of the masking character. + +```php +// Called as TextHelper +echo $this->Text->maskValue('4111111111111234', ['411', '112'], '*'); + +// Called as Text +use Cake\Utility\Text; + +echo Text::maskValue('4111111111111234', ['411', '112'], '*'); +``` + +Output: + + ***11111111***34 From 32d8c4af62646c6fd55075e40f9f056faba4e85c Mon Sep 17 00:00:00 2001 From: Masatoshi Ogiwara Date: Sun, 3 May 2026 17:33:05 +0900 Subject: [PATCH 28/57] =?UTF-8?q?chore:=20delete=20redundant=20particle=20?= =?UTF-8?q?=E3=82=92=20(#8292)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ja/core-libraries/hash.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ja/core-libraries/hash.md b/docs/ja/core-libraries/hash.md index 35b8ed377e..5b0b588480 100644 --- a/docs/ja/core-libraries/hash.md +++ b/docs/ja/core-libraries/hash.md @@ -636,7 +636,7 @@ public function noop(array $array) `static` Cake\\Utility\\Hash::**reduce**(array $data, $path, $function) -`$path` で抽出し、抽出結果を `$function` で縮小(reduce)することでを単一の値を作ります。 +`$path` で抽出し、抽出結果を `$function` で縮小(reduce)することで単一の値を作ります。 このメソッドでは式とマッチャーの両方を使うことができます。 `static` Cake\\Utility\\Hash::**apply**(array $data, $path, $function) From f420015422e3b29602634a99fd65896ecdccc954 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 3 May 2026 18:30:14 +0200 Subject: [PATCH 29/57] Add JsonStreamResponse documentation for 5.4 (#8255) * docs: Add JsonStreamResponse documentation for 5.4 * fix: Add blank line before code block for markdown lint * docs: Cross-link JsonView section to JsonStreamResponse Add a short pointer from the JsonView documentation to the streaming response section so users with large payloads discover the streaming alternative directly from the view docs. --- docs/en/appendices/5-4-migration-guide.md | 7 ++ docs/en/controllers/request-response.md | 137 ++++++++++++++++++++++ docs/en/views/json-and-xml-views.md | 23 ++++ 3 files changed, 167 insertions(+) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index a7440c2660..dc098f5293 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -104,6 +104,13 @@ version is reported as `unknown`), the header is omitted. `FormProtectionComponent`. See [Form Protection Component](../controllers/components/form-protection). +### Http + +- Added `JsonStreamResponse` class for memory-efficient streaming of large JSON + datasets using generators. Supports standard JSON arrays and NDJSON formats, + envelope structures with metadata, transform callbacks, and graceful mid-stream + error handling. See [Streaming JSON Responses](../controllers/request-response#streaming-json-responses). + ### Database - Added `notBetween()` method for `NOT BETWEEN` expressions. diff --git a/docs/en/controllers/request-response.md b/docs/en/controllers/request-response.md index 9f2e0a64b4..78d20b87f0 100644 --- a/docs/en/controllers/request-response.md +++ b/docs/en/controllers/request-response.md @@ -790,6 +790,143 @@ public function sendIcs() } ``` + + +### Streaming JSON Responses + +`class` Cake\\Http\\Response\\**JsonStreamResponse** + +When working with large datasets, loading everything into memory before encoding +to JSON can exhaust available memory. `JsonStreamResponse` provides memory-efficient +streaming of JSON data using generators, keeping only one item in memory at a time. + +::: info Added in version 5.4.0 +::: + +#### Basic Usage + +```php +use Cake\Http\Response\JsonStreamResponse; + +public function index() +{ + $query = $this->Articles->find(); + + // Simple array streaming + return new JsonStreamResponse($query); + // Output: [{"id":1,"title":"First"},{"id":2,"title":"Second"},...] +} +``` + +#### Constructor Options + +The `JsonStreamResponse` constructor accepts an iterable and an options array: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `root` | `string\|null` | `null` | Wrap data in `{"root": [...]}` | +| `envelope` | `array` | `[]` | Static metadata merged with streaming data | +| `dataKey` | `string` | `'data'` | Key for streaming data when envelope is used | +| `format` | `string` | `'json'` | Output format: `'json'` or `'ndjson'` | +| `transform` | `callable\|null` | `null` | Transform each item before encoding | +| `flags` | `int` | `DEFAULT_JSON_FLAGS` | JSON encode flags | + +#### With Root Wrapper + +Wrap the array in an object with a named key: + +```php +return new JsonStreamResponse($query, ['root' => 'articles']); +// Output: {"articles":[{"id":1,"title":"First"},{"id":2,"title":"Second"}]} +``` + +#### With Envelope (Metadata) + +Include static metadata alongside the streaming data: + +```php +$total = $this->Articles->find()->count(); + +return new JsonStreamResponse($query, [ + 'envelope' => ['meta' => ['total' => $total, 'page' => 1]], + 'dataKey' => 'articles', +]); +// Output: {"meta":{"total":100,"page":1},"articles":[{"id":1,"title":"First"},...]} +``` + +#### NDJSON Format + +[NDJSON](http://ndjson.org/) (Newline Delimited JSON) outputs one JSON object per +line, useful for streaming to clients that process data incrementally: + +```php +return new JsonStreamResponse($query, ['format' => 'ndjson']); +// Output: +// {"id":1,"title":"First"} +// {"id":2,"title":"Second"} +``` + +The content type is automatically set to `application/x-ndjson; charset=UTF-8`. + +#### Transform Callback + +Transform each item before JSON encoding. Useful for selecting specific fields +or formatting data: + +```php +return new JsonStreamResponse($query, [ + 'transform' => fn($article) => [ + 'id' => $article->id, + 'title' => $article->title, + 'url' => Router::url(['action' => 'view', $article->id]), + ], +]); +``` + +#### Immutability + +`JsonStreamResponse` follows PSR-7 immutability patterns. Use `withStreamOptions()` +to create a modified copy: + +```php +$response = new JsonStreamResponse($query); +$newResponse = $response->withStreamOptions(['root' => 'articles']); +``` + +#### Error Handling + +`JsonStreamResponse` uses a three-layer error handling strategy: + +1. **Pre-validation**: The first item is encoded before output starts. If encoding + fails, an exception is thrown and a proper error response can be returned. + +2. **Mid-stream error marker**: If item N (where N > 1) fails to encode, an error + marker is output to maintain valid JSON structure: + + ```json + [{"id":1},{"__streamError":{"message":"Type is not supported","index":1}}] + ``` + +3. **Server-side logging**: All encoding failures are logged via `Log::error()`. + +#### ORM Integration + +For true memory-efficient streaming, use unbuffered queries and avoid result +formatters: + +```php +// Good - streams one row at a time +$query = $this->Articles->find()->bufferResults(false); +return new JsonStreamResponse($query); + +// Avoid - formatters like map(), combine() buffer results internally +$query = $this->Articles->find()->map(fn($row) => $row); // Breaks streaming +``` + +> [!NOTE] +> Result formatters (`map()`, `combine()`, etc.) buffer results internally, +> which defeats the memory-efficient streaming purpose. + ### Setting Headers `method` Cake\\Http\\Response::**withHeader**(string $name, string|array $value): static diff --git a/docs/en/views/json-and-xml-views.md b/docs/en/views/json-and-xml-views.md index 2c46c68054..5e73a09329 100644 --- a/docs/en/views/json-and-xml-views.md +++ b/docs/en/views/json-and-xml-views.md @@ -200,6 +200,29 @@ $this->viewBuilder() ->setOption('jsonOptions', JSON_FORCE_OBJECT); ``` +### Streaming Large JSON Payloads + +`JsonView` is a good fit when your action can serialize the complete payload in +memory. For large result sets, use `Cake\Http\Response\JsonStreamResponse` +instead and return it directly from the controller: + +```php +use Cake\Http\Response\JsonStreamResponse; + +public function export() +{ + $query = $this->Articles->find() + ->enableHydration(false) + ->bufferResults(false); + + return new JsonStreamResponse($query); +} +``` + +See [Streaming JSON Responses](../controllers/request-response#streaming-json-responses) +for the available options, including NDJSON output, item transforms, and +graceful mid-stream error handling. + ### JSONP Responses When using `JsonView` you can use the special view variable `jsonp` to From 67192537430bfbb2d47b0055b18d68227e171c75 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sun, 3 May 2026 19:02:10 +0200 Subject: [PATCH 30/57] add documentation about events hook change (#8293) --- docs/en/appendices/5-4-migration-guide.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index dc098f5293..6caa212b25 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -34,6 +34,14 @@ The CakePHP version header in help output is now only shown when the CakePHP version can be determined. When used outside a CakePHP application (where the version is reported as `unknown`), the header is omitted. +### Events + +Events being registered in either `Application::events()` or `Plugin::events()` +now work in both web and CLI contexts. It is therefore highly recommended to +move your event listeners from the `config/bootstrap.php` file to the +`events()` method in your `Application` or `Plugin` class. +See [Application and Plugin Events](../core-libraries/events#registering-event-listeners) for more details. + ### I18n - `Number::parseFloat()` now returns `null` instead of `0.0` when parsing From 8b01462aa4ce97dc2f763030fc3c1d5fb967db27 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Sat, 9 May 2026 09:55:59 +0200 Subject: [PATCH 31/57] add ApcuEngine options section --- docs/en/core-libraries/caching.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/en/core-libraries/caching.md b/docs/en/core-libraries/caching.md index 904a99c17a..eb22bab848 100644 --- a/docs/en/core-libraries/caching.md +++ b/docs/en/core-libraries/caching.md @@ -163,6 +163,17 @@ FileEngine uses the following engine specific options: - `mask` The mask used for created files - `path` Path to where cachefiles should be saved. Defaults to system's temp dir. +### ApcuEngine Options + +ApcuEngine does not have any engine specific options. It requires the +[APCu](https://php.net/apcu) extension to be installed and enabled. + +Because APCu stores values in the local webserver process shared memory, it is +best suited for data that can be regenerated and does not need to be shared +between servers. When clearing cache data, ApcuEngine removes entries matching +the cache configuration's `prefix`, so use unique prefixes for each cache +configuration that uses APCu. + ### RedisEngine Options From 560db6c1a1479a7322053ad2ed9b53bea12998e1 Mon Sep 17 00:00:00 2001 From: ADmad Date: Sat, 9 May 2026 16:07:25 +0530 Subject: [PATCH 32/57] Fix config name in caching documentation (#8300) --- docs/en/core-libraries/caching.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/core-libraries/caching.md b/docs/en/core-libraries/caching.md index 904a99c17a..30ded4e124 100644 --- a/docs/en/core-libraries/caching.md +++ b/docs/en/core-libraries/caching.md @@ -53,7 +53,7 @@ process. Cache engine configurations are defined in **config/app.php**. For optimal performance CakePHP requires two cache engines to be defined. -- `_cake_core_` is used for storing file maps, and parsed results of +- `_cake_translations_` is used for storing file maps, and parsed results of [Internationalization & Localization](../core-libraries/internationalization-and-localization) files. - `_cake_model_`, is used to store schema descriptions for your applications models. From 0ed3aee5fe2dd9574f2aaa927987a048096cc4a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Albert=20Cansado=20Sol=C3=A0?= Date: Tue, 12 May 2026 09:43:21 +0200 Subject: [PATCH 33/57] Fix code block (#8302) --- docs/en/development/testing.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/en/development/testing.md b/docs/en/development/testing.md index 4acde5c865..e6a767c67e 100644 --- a/docs/en/development/testing.md +++ b/docs/en/development/testing.md @@ -790,7 +790,9 @@ in order to truncate all dirty tables before each test. The following command will help you bake your factories: - bin/cake bake fixture_factory -h +```bash +bin/cake bake fixture_factory -h +``` Once your factories are [tuned](https://github.com/vierge-noire/cakephp-fixture-factories/blob/main/docs/factories.md), From c372fbe9eea86d72991a9cd182d21aa5bffb0e7b Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Tue, 12 May 2026 22:15:45 +0200 Subject: [PATCH 34/57] Document i18n cache key prefix and configurable cache config (5.next) (#8289) * Add docs for i18n cache key prefix and configurable cache config Documents the new `TranslatorRegistry::setCacheKeyPrefix()`, `clearInMemoryRegistry()` and `I18n::setCacheConfig()` APIs added in 5.4.0, with the multi-tenant translation cache isolation use case as the worked example. Also adds entries to the 5.4 migration guide. * Update docs for renamed `TranslatorRegistry::clear()` method Reflects the rename from `clearInMemoryRegistry()` after review feedback. * Update docs/en/core-libraries/internationalization-and-localization.md Co-authored-by: Mark Story --------- Co-authored-by: Mark Story --- docs/en/appendices/5-4-migration-guide.md | 9 ++ .../internationalization-and-localization.md | 88 +++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index 6caa212b25..342abd62bc 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -152,6 +152,15 @@ See [Application and Plugin Events](../core-libraries/events#registering-event-l - `Number::toReadableSize()` now uses decimal units (KB = 1000 bytes) by default. Binary units (KiB = 1024 bytes) can be enabled via parameter or `Number::setUseIecUnits()`. +- Added `TranslatorRegistry::setCacheKeyPrefix()` to isolate translator caches + per tenant when a custom loader produces different messages for the same + domain and locale. Accepts a static string or a `Closure` resolved on every + lookup. See [Isolating Translations Per Tenant](../core-libraries/internationalization-and-localization#isolating-translations-per-tenant). +- Added `TranslatorRegistry::clear()` to drop the in-memory translator map + without touching the persistent cacher. Intended for long-running workers + that switch tenants between jobs. +- Added `I18n::setCacheConfig()` to route translator persistence to a Cache + config other than the default `_cake_translations_`. ### ORM diff --git a/docs/en/core-libraries/internationalization-and-localization.md b/docs/en/core-libraries/internationalization-and-localization.md index f4b2c66c86..50af3dfcef 100644 --- a/docs/en/core-libraries/internationalization-and-localization.md +++ b/docs/en/core-libraries/internationalization-and-localization.md @@ -562,6 +562,94 @@ I18n::config('_fallback', function ($domain, $locale) { }); ``` +### Isolating Translations Per Tenant + +::: info Added in version 5.4.0 +The cache key prefix and configurable cache config APIs were added in 5.4.0. +::: + +If your application needs to serve tenant specific translated content for a given domain & locale, you need to use a cache key prefix to scope both translator cache data to the tenant. + +#### Cache Key Prefix + +`TranslatorRegistry::setCacheKeyPrefix()` adds a segment to both the persistent +cache key and the in-memory lookup bucket. It accepts either a static string or +a `Closure` that returns one. When given a `Closure`, it is evaluated on every +`get()` call, so the current tenant identifier is pulled *from* user-land +instead of being *pushed into* the registry: + +```php +use Cake\I18n\I18n; + +I18n::translators()->setCacheKeyPrefix( + fn (): string => TenantContext::current()?->id ?? '' +); +``` + +With a non-empty prefix the cache key becomes +`translations.{prefix}.{domain}.{locale}`. An empty resolved value disables +prefixing and keeps the legacy key format, so the API is fully backwards +compatible for non-multi-tenant applications. + +The `Closure` receives the requested package name and resolved locale +(`function (string $name, string $locale): string`), which lets you skip +prefixing for shared packages or vary the prefix per locale: + +```php +I18n::translators()->setCacheKeyPrefix( + function (string $name, string $locale): string { + // Shared packages (e.g. validation messages) stay un-prefixed. + if ($name === 'cake' || str_starts_with($name, 'shared/')) { + return ''; + } + + return TenantContext::current()?->id ?? ''; + } +); +``` + +Prefix values must match `[A-Za-z0-9._-]+` to stay safe across every built-in +cache engine. + +> [!NOTE] +> `setCacheKeyPrefix()` is unrelated to the gettext message context used by +> [`__x()`](#using-translation-functions). The "context" in `__x()` disambiguates +> two messages with the same source text; the cache key prefix isolates the +> *cache* of resolved messages. + +#### Resetting the In-Memory Registry + +Long-running workers (e.g. queue runners) that switch tenants between jobs +should drop the in-memory translator map between batches to bound memory +growth and ensure freshly-resolved tenants don't read another tenant's +in-memory translator. The persistent cacher and configured prefix/cacher +are left untouched: + +```php +foreach ($jobsByTenant as $tenantId => $jobs) { + TenantContext::set($tenantId); + // ... process jobs ... + I18n::translators()->clear(); +} +``` + +#### Choosing a Different Cache Config + +By default, translators are persisted to the `_cake_translations_` Cache +config. If you want a separate config — for example, to give translations +their own TTL or storage engine — call `I18n::setCacheConfig()` before any +translator is resolved: + +```php +// in config/bootstrap.php, before any __() / I18n call +I18n::setCacheConfig('_my_translations_'); +``` + +`setCacheConfig()` throws a `RuntimeException` if it is called after the +translators registry has been built, to surface ordering bugs loudly instead +of silently ignoring the setting. To swap the cacher *after* translators +have been built, use `I18n::translators()->setCacher()` directly. + ### Plurals and Context in Custom Translators The arrays used for `setMessages()` can be crafted to instruct the translator From 663c8982c341ec6823f55363c765f00dfcc32e7c Mon Sep 17 00:00:00 2001 From: Nicos Panayides Date: Wed, 13 May 2026 10:32:38 +0300 Subject: [PATCH 35/57] Document that i18n extract now supports the extraction of Label attributes (#8304) --- docs/en/appendices/5-4-migration-guide.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index 342abd62bc..e3795cc9cf 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -161,6 +161,7 @@ See [Application and Plugin Events](../core-libraries/events#registering-event-l that switch tenants between jobs. - Added `I18n::setCacheConfig()` to route translator persistence to a Cache config other than the default `_cake_translations_`. +- The `cake i18n extract` command now also extracts enum labels using the #[Label] attribute. ### ORM From 3a1c8dff421e7dfad0bedde5e526b5fa248afcd8 Mon Sep 17 00:00:00 2001 From: Kevin Pfeifer Date: Wed, 13 May 2026 09:36:26 +0200 Subject: [PATCH 36/57] add docs about new table entity assertion (#8303) * add docs about new table entity assertion * add docs about new table entity assertion --- docs/en/appendices/5-4-migration-guide.md | 4 ++++ docs/en/orm/table-objects.md | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index e3795cc9cf..aa7986d036 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -58,6 +58,10 @@ See [Application and Plugin Events](../core-libraries/events#registering-event-l these events were silently suppressed. They are now deferred until the outermost transaction commits, and discarded on rollback. See [Table Objects](../orm/table-objects#aftersavecommit) for more details. +- Table methods `save()`, `delete()`, `patchEntity()`, `patchEntities()` and `loadInto()` + will now throw an exception if the entity being passed down does not belong to the table instance. + This will prevent accidental data corruption or deleted records. If you don't want this new behavior, + you can disable it by calling `$this->disableEntityClassAssertion();` in your `initialize()` method. ### Controller diff --git a/docs/en/orm/table-objects.md b/docs/en/orm/table-objects.md index f3d8f95c90..d778e6416d 100644 --- a/docs/en/orm/table-objects.md +++ b/docs/en/orm/table-objects.md @@ -95,6 +95,15 @@ As seen in the examples above Table objects have an `initialize()` method which is called at the end of the constructor. It is recommended that you use this method to do initialization logic instead of overriding the constructor. +::: tip Added in version 5.4.0 +Table methods `save()`, `delete()`, `patchEntity()`, `patchEntities()` and `loadInto()` +will throw an exception if the entity being passed down does not belong to the table instance. +This will prevent accidental data corruption or deleted records. + +If you don't want this new behavior, you can disable it by calling +`$this->disableEntityClassAssertion();` in your `initialize()` method. +::: + ### Getting Instances of a Table Class Before you can query a table, you'll need to get an instance of the table. You From 56b1a166ea70ef0584a45f10cf99f5250709748b Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 17 May 2026 21:37:39 +0200 Subject: [PATCH 37/57] Document Table::findUnhydrated() and SelectUnhydratedQuery Adds a 'Getting Arrays Instead of Entities' section covering findUnhydrated() as the type-safe replacement for find()->disableHydration(), updates the buffered-results and mapReduce examples to use it, and records the new feature plus the disableHydration() deprecation in the 5.4 migration guide. --- docs/en/appendices/5-4-migration-guide.md | 12 +++++ docs/en/orm/retrieving-data-and-resultsets.md | 52 ++++++++++++++----- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index aa7986d036..3a876ee869 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -79,6 +79,13 @@ See [Application and Plugin Events](../core-libraries/events#registering-event-l - The `Mailer::$name` property is unused and has been deprecated. +### ORM + +- `SelectQuery::disableHydration()` has been deprecated. Use + [`Table::findUnhydrated()`](../orm/retrieving-data-and-resultsets#getting-arrays-instead-of-entities) + instead, which returns a `SelectUnhydratedQuery` whose static type matches the + array result shape. `disableHydration()` will be removed in 6.0. + ## New Features ### Core @@ -172,6 +179,11 @@ See [Application and Plugin Events](../core-libraries/events#registering-event-l - The `associated` option in `newEntity()` and `patchEntity()` now supports nested array format matching `contain()` syntax. See [Converting Request Data into Entities](../orm/saving-data#converting-request-data-into-entities). +- Added `Table::findUnhydrated()` and the `SelectUnhydratedQuery` class for + type-safe non-hydrated reads. Unlike `find()->disableHydration()`, the + returned query's static type matches its array result shape, so static + analyzers no longer see `entity|array` on `first()`, `all()`, `toArray()` + and iteration. See [Getting Arrays Instead of Entities](../orm/retrieving-data-and-resultsets#getting-arrays-instead-of-entities). ### Testsuite diff --git a/docs/en/orm/retrieving-data-and-resultsets.md b/docs/en/orm/retrieving-data-and-resultsets.md index 132b9fa6d4..3749e2638b 100644 --- a/docs/en/orm/retrieving-data-and-resultsets.md +++ b/docs/en/orm/retrieving-data-and-resultsets.md @@ -179,16 +179,46 @@ you can pass query objects to your controllers, we recommend that you package your queries up as [Custom Find Methods](#custom-find-methods) instead. Using custom finder methods will let you re-use your queries and make testing easier. -By default, queries and result sets will return [Entities](../orm/entities) objects. You -can retrieve basic arrays by disabling hydration: + + +### Getting Arrays Instead of Entities + +By default, queries and result sets return [Entities](../orm/entities) objects. +When you only need plain arrays, use `findUnhydrated()` instead of `find()`: ```php -$query->disableHydration(); +$query = $articles->findUnhydrated(); -// $data is ResultSet that contains array data. +// $data is a ResultSet that contains array data. $data = $query->all(); + +// Terminal methods are typed as arrays too. +$row = $articles->findUnhydrated()->where(['id' => 1])->first(); // array|null +``` + +`findUnhydrated()` accepts the same finder type and arguments as `find()`, so +your existing [custom finders](#custom-find-methods) are reused unchanged: + +```php +$rows = $articles->findUnhydrated('published')->all(); ``` +It returns a `Cake\ORM\Query\SelectUnhydratedQuery`. This behaves exactly like +`find()->disableHydration()` at runtime, but its static type matches the array +result shape, so static analyzers no longer see `entity|array` on `first()`, +`firstOrFail()`, `all()`, `toArray()` and iteration — including after the query +flows through a custom finder. + +> [!NOTE] +> `findUnhydrated()` only changes the result shape for row-returning finders. +> `findList()` and `findThreaded()` produce a key/value map or nested tree +> regardless of hydration, so there is nothing to type differently for them. + +> [!WARNING] +> `SelectQuery::disableHydration()` is deprecated as of 5.4.0 and will be +> removed in 6.0. The fluent toggle returns a query whose static type still +> claims to produce entities; prefer `findUnhydrated()` instead. + ## Getting the First Result @@ -1071,15 +1101,15 @@ section show how you can add calculated fields, or replace the result set. > ->all(); > ``` > -> Depending on your use case, you may also consider using disabling hydration: +> Depending on your use case, you may also consider skipping hydration: > > ``` bash -> $results = $articles->find() -> ->disableHydration() +> $results = $articles->findUnhydrated() > ->all(); > ``` > -> The above will disable creation of entity objects and return rows as arrays instead. +> The above will skip creation of entity objects and return rows as arrays instead. +> See [Getting Arrays Instead of Entities](#getting-arrays-instead-of-entities). ### Getting the First & Last Record From a ResultSet @@ -1244,10 +1274,9 @@ $reducer = function ($occurrences, $word, $mapReduce) { Finally, we put everything together: ```php -$wordCount = $articles->find() +$wordCount = $articles->findUnhydrated() ->where(['published' => true]) ->andWhere(['published_date >=' => new DateTime('2014-01-01')]) - ->disableHydration() ->mapReduce($mapper, $reducer) ->all() ->toArray(); @@ -1317,8 +1346,7 @@ $reducer = function ($friends, $user, $mr) { And we supply our functions to a query: ```php -$fakeFriends = $friends->find() - ->disableHydration() +$fakeFriends = $friends->findUnhydrated() ->mapReduce($mapper, $reducer) ->all() ->toArray(); From d7380bf824fb66c6e091f4479529cf93fa033228 Mon Sep 17 00:00:00 2001 From: mscherer Date: Wed, 20 May 2026 15:02:44 +0200 Subject: [PATCH 38/57] Document 5.4 features: PluralRules, EnumLabelTrait, FormHelper hidden default, subcommand validation, enumOptions Adds docs and 5.4 migration guide entries for several 5.next merges: - PluralRules::setRule() / resetRules() for custom Gettext plural rules. - EnumLabelTrait + Label attribute for derived translated enum labels. - FormHelper now wraps hidden CSRF / FormProtection blocks with the HTML5 boolean hidden attribute instead of inline style display:none, so the default markup is strict-CSP compatible. - CLI rejects unknown positional tokens after a parent command that has sibling subcommands, surfacing typos instead of silently dropping them. - FormHelper::enumOptions() is now public for building select options from a backed enum without entity context. --- docs/en/appendices/5-4-migration-guide.md | 30 ++++++++++ docs/en/console-commands/commands.md | 24 ++++++++ .../internationalization-and-localization.md | 59 +++++++++++++++++++ docs/en/orm/database-basics.md | 49 ++++++++++++++- docs/en/views/helpers/form.md | 32 +++++++++- 5 files changed, 190 insertions(+), 4 deletions(-) diff --git a/docs/en/appendices/5-4-migration-guide.md b/docs/en/appendices/5-4-migration-guide.md index aa7986d036..0f27b2d7c6 100644 --- a/docs/en/appendices/5-4-migration-guide.md +++ b/docs/en/appendices/5-4-migration-guide.md @@ -26,6 +26,14 @@ Running `bin/cake` without providing a command name no longer displays the "No command provided" error message. Instead, the `help` command is shown directly. +Unknown positional tokens following a parent command that has sibling +subcommands are now rejected with a clear error listing the available +subcommands. For example, `bin/cake i18n nonsense` previously silently +invoked the parent `I18nCommand` and discarded the trailing token; it now +errors out. Commands that intentionally accept arbitrary positional arguments +(e.g. `routes generate`) are unaffected. +See [Subcommand Validation](../console-commands/commands#subcommand-validation). + The `help` command is now hidden from command listings (via `CommandHiddenInterface`). It remains accessible by running `bin/cake help` or `bin/cake help `. @@ -68,6 +76,16 @@ See [Application and Plugin Events](../core-libraries/events#registering-event-l - Loading a component with the same alias as the controller's default table now triggers a warning. See [Component Alias Conflicts](../controllers/components#component-alias-conflicts). +### View + +- `FormHelper` now wraps hidden form blocks (CSRF, FormProtection, + `postLink()` / `postButton()`) with the HTML5 boolean `hidden` attribute + instead of an inline `style="display:none;"`. This makes the default markup + compatible with a strict Content-Security-Policy (no need for + `style-src 'unsafe-inline'`). If you previously selected those wrappers via + CSS (e.g. `div[style="display:none;"]`), switch to `[hidden]` or set the + `hiddenClass` template option to opt out and emit a class instead. + ## Deprecations ### Command Helpers @@ -145,6 +163,11 @@ See [Application and Plugin Events](../core-libraries/events#registering-event-l provides constants (`Index::GIN`, `Index::GIST`, `Index::SPGIST`, `Index::BRIN`, `Index::HASH`) for these access methods. See [Reading Indexes and Constraints](../orm/schema-system#reading-indexes-and-constraints). +- Added `Cake\Database\Type\EnumLabelTrait` and the + `Cake\Database\Type\Attribute\Label` attribute. The trait provides a default + `label()` implementation backed by the translator and the attribute lets + individual cases override the derived label. See + [EnumLabelTrait and the Label Attribute](../orm/database-basics#enumlabeltrait-and-the-label-attribute). ### Http @@ -166,6 +189,10 @@ See [Application and Plugin Events](../core-libraries/events#registering-event-l - Added `I18n::setCacheConfig()` to route translator persistence to a Cache config other than the default `_cake_translations_`. - The `cake i18n extract` command now also extracts enum labels using the #[Label] attribute. +- Added `PluralRules::setRule()` to register a custom Gettext plural rule for + a locale whose built-in form is missing or differs from the layout used by + your .po/.mo files. See + [Customizing Plural Rules](../core-libraries/internationalization-and-localization#customizing-plural-rules). ### ORM @@ -191,3 +218,6 @@ See [Application and Plugin Events](../core-libraries/events#registering-event-l - Added `{{inputId}}` template variable to `inputContainer` and `error` templates in FormHelper. See [Built-in Template Variables](../views/helpers/form#built-in-template-variables). +- `FormHelper::enumOptions()` is now public. This lets you build `select` + options from a backed enum class even when the form was created without + an entity context. See [Creating Select Pickers](../views/helpers/form#creating-select-pickers). diff --git a/docs/en/console-commands/commands.md b/docs/en/console-commands/commands.md index b404286af2..84aef7e512 100644 --- a/docs/en/console-commands/commands.md +++ b/docs/en/console-commands/commands.md @@ -318,6 +318,30 @@ Usage: cake user [-h] [-q] [-v] ``` +## Subcommand Validation + +::: info Added in version 5.4.0 +Strict validation for unknown subcommands was added in 5.4.0. +::: + +When a parent command has registered subcommands (e.g. `i18n extract`, +`i18n init`), CakePHP rejects unknown positional tokens that follow the +parent name. Previously, typos such as `bin/cake i18n nonsense` silently +invoked the parent command and discarded the trailing token; now you get a +clear error listing the available subcommands: + +```text +$ bin/cake i18n nonsense +Error: Unknown command `cake i18n nonsense`. +Available subcommands: `i18n extract`, `i18n init`. +Run `cake i18n --help` to see usage. +``` + +This only kicks in when the parent command has sibling subcommands. Commands +that accept arbitrary positional arguments (e.g. `routes generate`) are +unaffected, and option-like tokens (`--help`, `-v`) following the command +name continue to be forwarded to the parser. + ## Grouping Commands By default, in the help output CakePHP will group commands into core, app, and diff --git a/docs/en/core-libraries/internationalization-and-localization.md b/docs/en/core-libraries/internationalization-and-localization.md index 50af3dfcef..c3aa0bed2c 100644 --- a/docs/en/core-libraries/internationalization-and-localization.md +++ b/docs/en/core-libraries/internationalization-and-localization.md @@ -425,6 +425,65 @@ msgstr[2] "{0} datoteka je uklonjeno" Please visit the [Launchpad languages page](https://translations.launchpad.net/+languages) for a detailed explanation of the plural form numbers for each language. +#### Customizing Plural Rules + +::: info Added in version 5.4.0 +`PluralRules::setRule()` and `PluralRules::resetRules()` were added in 5.4.0. +::: + +When `__n()` / `__dn()` and the other Gettext-style plural functions resolve a +message, CakePHP picks the plural form via `Cake\I18n\PluralRules::calculate()`. +The built-in rules cover most CLDR locales, but they can lag behind upstream +CLDR releases and they do not cover every minority language. If you hit a +locale whose plural form is missing or wrong, you can register a custom rule +without patching CakePHP: + +```php +use Cake\I18n\PluralRules; + +// Breton: 5 plural forms (CLDR) +PluralRules::setRule('br', function (int $n): int { + if ($n % 10 === 1 && $n % 100 !== 11 && $n % 100 !== 71 && $n % 100 !== 91) { + return 0; + } + if ($n % 10 === 2 && $n % 100 !== 12 && $n % 100 !== 72 && $n % 100 !== 92) { + return 1; + } + if (in_array($n % 10, [3, 4, 9], true) + && !in_array($n % 100, [13, 14, 19, 73, 74, 79, 93, 94, 99], true) + ) { + return 2; + } + if ($n !== 0 && $n % 1_000_000 === 0) { + return 3; + } + + return 4; +}); +``` + +The closure receives the integer count and must return the zero-based plural +form index that matches the `msgstr[N]` entries in your **.po** / **.mo** +files. Custom rules take precedence over the built-in map, so they can also +be used to override a built-in rule that does not match the form layout used +by your translation files. + +Register rules in **config/bootstrap.php** so they are available before any +translation is requested. The locale string is normalized via +`Locale::canonicalize()` and an invalid locale throws an +`InvalidArgumentException`. To drop all registered custom rules (typically +between tests), call: + +```php +PluralRules::resetRules(); +``` + +> [!NOTE] +> `PluralRules` is only consulted for Gettext-style messages +> (`__n()`, `__dn()`, `msgstr[0]` / `msgstr[1]` / …). The ICU plural selector +> shown above resolves its own forms via `MessageFormatter` and is unaffected +> by `setRule()`. + ## Creating Your Own Translators If you need to diverge from CakePHP conventions regarding where and how diff --git a/docs/en/orm/database-basics.md b/docs/en/orm/database-basics.md index 94a5e87e59..d95b6daf1d 100644 --- a/docs/en/orm/database-basics.md +++ b/docs/en/orm/database-basics.md @@ -596,7 +596,54 @@ enum ArticleStatus: string implements EnumLabelInterface ``` This can be useful if you want to use your enums in `FormHelper` select -inputs. You can use [bake](../bake) to generate an enum class: +inputs. + +#### EnumLabelTrait and the Label Attribute + +::: info Added in version 5.4.0 +`Cake\Database\Type\EnumLabelTrait` and the +`Cake\Database\Type\Attribute\Label` attribute were added in 5.4.0. +::: + +Writing the `label()` `match` block by hand becomes repetitive once an enum +grows past a few cases. `EnumLabelTrait` provides a default `label()` +implementation that derives the label from the case name and resolves it +through the translator. Cases can override the derived label with the +`#[Label]` attribute: + +```php +namespace App\Model\Enum; + +use Cake\Database\Type\Attribute\Label; +use Cake\Database\Type\EnumLabelInterface; +use Cake\Database\Type\EnumLabelTrait; + +enum ArticleStatus: string implements EnumLabelInterface +{ + use EnumLabelTrait; + + case Published = 'Y'; + + #[Label('Not yet published')] + case Unpublished = 'N'; + + #[Label('Archived', domain: 'articles', context: 'status')] + case Archived = 'A'; +} +``` + +For a case **without** a `#[Label]` attribute, the trait humanizes the case +name (`Unpublished` → `Unpublished`, `InReview` → `In review`) and runs it +through the translator. For cases **with** a `#[Label]`, the explicit label +string is used and is translated using the optional `domain` and `context` +constructor arguments. Labels are extracted by `cake i18n extract`, which +detects the `#[Label]` attribute and emits one msgid per case. + +> [!TIP] +> Pair `EnumLabelTrait` with `EnumLabelInterface` so type-aware consumers +> (e.g. `FormHelper`'s automatic enum support) keep working. + +You can use [bake](../bake) to generate an enum class: ```bash # generate an enum class with two cases and stored as an integer diff --git a/docs/en/views/helpers/form.md b/docs/en/views/helpers/form.md index 3ece9b408c..526f3400df 100644 --- a/docs/en/views/helpers/form.md +++ b/docs/en/views/helpers/form.md @@ -1462,6 +1462,24 @@ Output: ``` +To build `$options` from a backed enum, you can use `enumOptions()`: + +```php +use App\Model\Enum\ArticleStatus; + +echo $this->Form->select('status', $this->Form->enumOptions(ArticleStatus::class)); +``` + +When `ArticleStatus` implements `EnumLabelInterface` (or uses +`EnumLabelTrait`), the option text is taken from `label()`; otherwise the +case name is used. This is useful when the form was created without an +entity context, where the automatic enum detection on `control()` does not +apply. + +::: info Added in version 5.4.0 +`FormHelper::enumOptions()` was made public in 5.4.0. +::: + **Controlling Select Pickers via Attributes** By using specific options in the `$attributes` parameter you can control @@ -2140,7 +2158,7 @@ echo $this->Form->end(['data-type' => 'hidden']); Will output: ```html -
+