{"id":8397,"date":"2026-09-24T07:58:36","date_gmt":"2026-09-24T07:58:36","guid":{"rendered":"https:\/\/poeditor.com\/blog\/?p=8397"},"modified":"2026-09-24T08:07:33","modified_gmt":"2026-09-24T08:07:33","slug":"cakephp-localization","status":"publish","type":"post","link":"https:\/\/poeditor.com\/blog\/cakephp-localization\/","title":{"rendered":"CakePHP localization: How to translate a CakePHP app with i18n"},"content":{"rendered":"\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"578\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/CakePHP-localization-1024x578.png\" alt=\"\" class=\"wp-image-8427\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/CakePHP-localization-1024x578.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/CakePHP-localization-767x433.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/CakePHP-localization-300x169.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/CakePHP-localization-1536x867.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/CakePHP-localization.png 1757w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">CakePHP relies on conventions to keep configuration simple. For example, if you name a table <code>todo_lists<\/code>, the framework expects the related table class, controller and template folder to follow the same naming pattern. This makes it easier to navigate an unfamiliar CakePHP project.&nbsp;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">These conventions do not cover the language used by the application. If you are building for multiple markets, labels, buttons and messages need to be translated, while dates and numbers need to follow the conventions of each locale. Internationalization (i18n) prepares the application for translation, while localization (l10n) applies the translations and locale-specific formats.&nbsp;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In this tutorial, we\u2019ll localize a CakePHP TO-DO app with English strings hardcoded in its templates and controllers. We\u2019ll mark those strings for translation, extract them into a catalog, translate the catalog into French in POEditor, and load the translated files back into the app.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What CakePHP already handles for you<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">CakePHP 5 includes its i18n package and uses PHP\u2019s <code>intl<\/code> extension for locale-aware formatting. <code>Cake\\I18n\\Number<\/code> and <code>Cake\\I18n\\DateTime<\/code> format a value according to whichever locale is active, and the framework\u2019s own strings live in a message domain called cake, whose translations are published separately, in the cakephp\/localized plugin.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">CakePHP cannot know which strings in your application should be translated. The TO-DO app we\u2019re starting from has username and password registration, a dashboard listing the account\u2019s to-do lists, and a page per list where tasks are added, edited, filtered and ticked off. Every one of its strings is an English literal sitting in a template or a controller.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"925\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/01_list_with_tasks-1024x925.png\" alt=\"\" class=\"wp-image-8411\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/01_list_with_tasks-1024x925.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/01_list_with_tasks-300x271.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/01_list_with_tasks-767x693.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/01_list_with_tasks-1536x1387.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/01_list_with_tasks-2048x1849.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>The TO-DO app before translation, with its text hardcoded in English<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The screenshot has a second problem too. The line reading <code>5 tasks<\/code> left comes from a hand-written <code>if <\/code>that picks between task and tasks, and the dates use <code>format('n\/j\/Y g:i A')<\/code>, the American convention hardcoded into the template. Neither becomes correct for a French reader just because the words around them have been translated.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">To follow along you need PHP 8.2 or newer with the <code>intl<\/code> extension enabled, since current CakePHP 5 releases require both, and a CakePHP application you can edit. Install the dependencies with <code>composer install<\/code>, start the application with <code>bin\/cake server<\/code>, and open the address it prints.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Setting the default locale<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The locale CakePHP falls back on lives in <code>config\/app.php<\/code> under App.defaultLocale, and a stock application reads it from the environment so you can override it per deployment.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em>\/\/ config\/app.php<\/em><br>'App' =&gt; &#091;<br>&nbsp; &nbsp; 'defaultLocale' =&gt; env('APP_DEFAULT_LOCALE', 'en_US'),<br>&nbsp; &nbsp; <em>\/\/ ...<\/em><br>],<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">That one value decides more than which translations are loaded. It also sets the default formats used by <code>Cake\\I18n\\Number<\/code> and <code>Cake\\I18n\\DateTime<\/code>, so changing it moves dates and percentages to French conventions without any translated text being involved.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Locale codes follow the ICU convention of a language and an optional region, written as <code>en_US<\/code>, <code>fr_FR<\/code> or <code>fr_CA<\/code>. A bare <code>fr<\/code> is valid too, and it is what this tutorial uses, because one catalog then serves every French-speaking region. Name the region only when you will ship a catalog that differs by region, such as a <code>fr_CA<\/code> written for Canada.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Record the languages the app ships in while you are here, because both the middleware and the language switcher need that list.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em>\/\/ config\/app.php<\/em><br>'I18n' =&gt; &#091;<br>&nbsp; &nbsp; 'locales' =&gt; &#091;<br>&nbsp; &nbsp; &nbsp; &nbsp; 'en_US' =&gt; 'English',<br>&nbsp; &nbsp; &nbsp; &nbsp; 'fr' =&gt; 'Fran\u00e7ais',<br>&nbsp; &nbsp; ],<br>],<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">The __() function family<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">CakePHP exposes its translation functions globally, so they are available in controllers, templates, table classes and anywhere else without an import. There are eight, built from three ideas that combine in a fixed order.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Function<\/th><th>What it adds<\/th><\/tr><\/thead><tbody><tr><td>__()<\/td><td>Translate a string<\/td><\/tr><tr><td>__d()<\/td><td>\u2026from a specific <strong>d<\/strong>omain<\/td><\/tr><tr><td>__n()<\/td><td>\u2026choosing between singular and plural by a <strong>n<\/strong>umber<\/td><\/tr><tr><td>__x()<\/td><td>\u2026with a conte<strong>x<\/strong>t that disambiguates identical strings<\/td><\/tr><tr><td>__dn()<\/td><td>Domain and plural<\/td><\/tr><tr><td>__dx()<\/td><td>Domain and context<\/td><\/tr><tr><td>__xn()<\/td><td>Context and plural<\/td><\/tr><tr><td>__dxn()<\/td><td>Domain, context and plural<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Read the names as <code>d<\/code> for domain, <code>x<\/code> for context and <code>n<\/code> for plural. Most of your time goes in <code>__()<\/code> and <code>__n()<\/code>, and each of them returns its source string unchanged when no translation exists, so marking every string in one pass is safe and a string added after the last translation round still renders in English.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Translating templates<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Start with the layout, because it wraps every page. In the TO-DO app that is <code>templates\/layout\/default.php<\/code>, where the brand, the navigation buttons and the signed-in line all need wrapping.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><strong>&lt;?php<\/strong> <em>\/\/ templates\/layout\/default.php<\/em> <strong>?&gt;<\/strong><br>&lt;a <strong>class<\/strong>=\"navbar-brand fw-semibold\" href=\"&lt;?= $this-&gt;Url-&gt;build('\/') ?&gt;\"&gt;&lt;?= h(__('TO-DO app')) <strong>?&gt;<\/strong>&lt;\/a&gt;<br><br>&lt;span <strong>class<\/strong>=\"text-body-secondary small\"&gt;<br>&nbsp; &nbsp; &lt;?= h(__('Signed in as {0}', $identity-&gt;get('username'))) <strong>?&gt;<\/strong><br>&lt;\/span&gt;<br><br>&lt;?= $this-&gt;Form-&gt;postLink(<br>&nbsp; &nbsp; __('Log out'),<br>&nbsp; &nbsp; &#091;'controller' =&gt; 'Users', 'action' =&gt; 'logout'],<br>&nbsp; &nbsp; &#091;'class' =&gt; 'btn btn-sm btn-outline-secondary'],<br>) <strong>?&gt;<\/strong><\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Translate first and escape the result, never the other way round, because escaping first would hand the translator a string full of HTML entities. Helpers such as <code>Form<\/code> and <code>Html<\/code> take their labels as ordinary arguments, so a label gets wrapped in <code>__()<\/code> like any other string.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The page templates follow the same pattern, and the strings that get overlooked are the ones that are not body text: placeholders, <code>aria-label<\/code> and <code>title<\/code> attributes, and the confirmation text on a delete button.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><strong>&lt;?php<\/strong> <em>\/\/ templates\/TodoLists\/index.php<\/em> <strong>?&gt;<\/strong><br><strong>&lt;?<\/strong>= $this-&gt;Form-&gt;control('name', &#091;<br>&nbsp; &nbsp; 'label' =&gt; __('New list'),<br>&nbsp; &nbsp; 'placeholder' =&gt; __('Groceries'),<br>]) <strong>?&gt;<\/strong><br><br><strong>&lt;?<\/strong>= $this-&gt;Form-&gt;button(__('Create list'), &#091;'class' =&gt; 'btn btn-primary w-100']) <strong>?&gt;<\/strong><br><br>&lt;p <strong>class<\/strong>=\"text-body-secondary\"&gt;&lt;?= h(__('No lists yet. Create your first one above.')) <strong>?&gt;<\/strong>&lt;\/p&gt;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">One string stays unmarked. The username field\u2019s placeholder reads <code>demo<\/code> because that is the account name this walkthrough asks you to type, and a value that is data rather than prose is better left out of the catalog.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Translating controllers and flash messages<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Controllers hold the messages a user sees after doing something, and in the TO-DO app those were built by concatenation.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em>\/\/ src\/Controller\/TodoListsController.php<\/em><br>$this-&gt;Flash-&gt;success('List \"' . $todoList-&gt;name . '\" created.');<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A concatenated message is difficult to translate because the translator does not get the complete sentence. They see <code>List \"<\/code>, followed by the list name, and then <code>\" created<\/code>. This also prevents them from changing the word order when the target language requires a different sentence structure. Make the whole message one string and pass the variable as a placeholder.&nbsp;<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em>\/\/ src\/Controller\/TodoListsController.php<\/em><br>$this-&gt;Flash-&gt;success(__('List \"{0}\" created.', $todoList-&gt;name));<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>{0}<\/code> is ICU MessageFormat, CakePHP\u2019s default way of interpolating arguments. The numbering allows the translator to change the word order. For example, a French translation can move <code>{0}<\/code> to a different position in the sentence. Messages with two values use <code>{0}<\/code> and <code>{1}<\/code> as their placeholders.&nbsp;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Page titles and anything else the controller sets get the same treatment.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em>\/\/ src\/Controller\/TodoListsController.php<\/em><br>$this-&gt;set('title', __('Your lists'));<br><br>$filters = &#091;<br>&nbsp; &nbsp; &#091;'key' =&gt; 'all', 'label' =&gt; __('All'), 'count' =&gt; $totalCount],<br>&nbsp; &nbsp; &#091;'key' =&gt; 'open', 'label' =&gt; __('Open'), 'count' =&gt; $openCount],<br>&nbsp; &nbsp; &#091;'key' =&gt; 'done', 'label' =&gt; __('Done'), 'count' =&gt; $doneCount],<br>];<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Translating validation messages and message domains<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Validation messages in CakePHP live in table classes rather than templates, which makes them easy to forget until a French user submits an empty form and gets an English complaint back. In the TO-DO app they sit in <code>validationDefault()<\/code> and <code>buildRules()<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is also where we can introduce a <strong>domain<\/strong>, which is a separate catalog file. Everything marked with plain <code>__()<\/code> goes into a domain called default, and <code>__d()<\/code> sends a message somewhere else.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em>\/\/ src\/Model\/Table\/UsersTable.php<\/em><br>$validator<br>&nbsp; &nbsp; -&gt;scalar('username')<br>&nbsp; &nbsp; -&gt;requirePresence('username', 'create')<br>&nbsp; &nbsp; -&gt;notEmptyString('username', __d('validation', 'Pick a username.'))<br>&nbsp; &nbsp; -&gt;lengthBetween('username', &#091;3, 80], __d('validation', 'Usernames are 3 to 80 characters long.'));<br><br>$rules-&gt;add(<br>&nbsp; &nbsp; $rules-&gt;isUnique(&#091;'username'], __d('validation', 'That username is taken.')),<br>&nbsp; &nbsp; &#091;'errorField' =&gt; 'username'],<br>);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">CakePHP also uses some predefined domains: the framework\u2019s own strings are in cake, and each plugin gets a domain named after it. Your own messages are the only ones that need marking.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Keeping form errors apart from interface labels gives a translator a coherent batch of similar strings to work through, at the cost of a second file to import and keep in sync.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Plurals: __n() or ICU MessageFormat<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The TO-DO app picks its singular and plural forms by hand in four places, and the dashboard is the clearest of them.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em>\/\/ src\/Controller\/TodoListsController.php<\/em><br><strong>if<\/strong> ($listCount === 1) {<br>&nbsp; &nbsp; $listCountLabel = $listCount . ' list';<br>} <strong>else<\/strong> {<br>&nbsp; &nbsp; $listCountLabel = number_format($listCount) . ' lists';<br>}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This assumes that every language has two plural forms and that the singular is used only for one item. That is not true for all languages. Polish has four plural forms, Arabic has six, and French uses the singular form for zero.&nbsp;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">CakePHP gives you two ways out.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>__n()<\/code><\/strong> <strong>takes a singular message, a plural message and the count<\/strong>. The catalog then stores the plural forms required by the target language.&nbsp;<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em>\/\/ src\/Controller\/TodoListsController.php<\/em><br>$listCountLabel = __n(<br>&nbsp; &nbsp; 'You have {0} list.',<br>&nbsp; &nbsp; 'You have {0} lists.',<br>&nbsp; &nbsp; $listCount,<br>&nbsp; &nbsp; Number::format($listCount),<br>);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>ICU plural syntax puts every form inside a single message<\/strong>, which one translator then fills in as one string.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$listCountLabel = __('{0,plural,=0{You have no lists.} one{You have one list.} other{You have # lists.}}', $listCount);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For this tutorial we\u2019ll use <code>__n()<\/code> throughout: it is what <code>bin\/cake i18n extract<\/code> writes into the catalog natively, and it is what POEditor\u2019s plural editor is built around. We recommend keeping plural forms separate rather than using ICU MessageFormat, since separate forms are easier to work with in the translation workflow. Replace the other three manual plural branches in the same way.&nbsp;<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Extracting the strings with bin\/cake i18n extract<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">With the strings marked, CakePHP\u2019s console command scans the code and writes a <code>.pot<\/code> file, which is the template catalog a translator works from.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>bin\/cake i18n extract --paths src,templates --output resources\/locales --extract-core no --overwrite<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Run bare, the command prompts for each of those values. <code>--extract-core<\/code> no leaves CakePHP\u2019s own strings out, <code>--overwrite<\/code> replaces the previous template instead of asking, and pointing <code>--paths<\/code> at <code>src<\/code> and <code>templates<\/code> avoids the reflection warnings that config produces for its migration and seed classes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The output lands in <code>resources\/locales\/<\/code>, one file per domain, so our split produces two.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resources\/locales\/<br>\u251c\u2500\u2500 default.pot<br>\u2514\u2500\u2500 validation.pot<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Open <code>default.pot<\/code> to see the structure of the extracted catalog.&nbsp;<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># resources\/locales\/default.pot<br>#: .\/src\/Controller\/TodoListsController.php:31<br>msgid \"You have {0} list.\"<br>msgid_plural \"You have {0} lists.\"<br>msgstr&#091;0] \"\"<br>msgstr&#091;1] \"\"<br><br>#: .\/src\/Controller\/TodoListsController.php:98<br>#: .\/templates\/TodoLists\/index.php:42<br>msgid \"{0} task left\"<br>msgid_plural \"{0} tasks left\"<br>msgstr&#091;0] \"\"<br>msgstr&#091;1] \"\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>#:<\/code> lines are source references, and the second entry carries two of them: the list page and the dashboard word their count the same way, so four call sites collapsed into three entries. Putting the whole sentence inside the message is what let them match. The <code>msgstr<\/code> values are empty because a <code>.pot<\/code> is a template: it holds the source messages and nothing else.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Extraction reads literals, so a string built at runtime and passed to <code>__()<\/code> as a variable never reaches your translator.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Translating the catalog in POEditor<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Editing a <code>.pot<\/code> file by hand becomes difficult as the number of strings grows, especially when translators or other non-developers need to work on the project. <a href=\"https:\/\/poeditor.com\/\">POEditor<\/a> gives the catalog a web interface, keeps a translation memory across your projects on paid plans, and hands back a <code>.po<\/code> file in the same format CakePHP already reads.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Start by creating a project for the application.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"616\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/02_projects_list_new_project-1024x616.png\" alt=\"\" class=\"wp-image-8412\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/02_projects_list_new_project-1024x616.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/02_projects_list_new_project-300x180.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/02_projects_list_new_project-766x461.png 766w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/02_projects_list_new_project-1536x924.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/02_projects_list_new_project-2048x1232.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>The project created for the TO-DO app<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Then add the languages the app ships in: English as the source and French as the target.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"635\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/03_languages_added_empty_project-1024x635.png\" alt=\"\" class=\"wp-image-8413\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/03_languages_added_empty_project-1024x635.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/03_languages_added_empty_project-300x186.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/03_languages_added_empty_project-768x476.png 768w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/03_languages_added_empty_project-1536x952.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/03_languages_added_empty_project-2048x1269.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>English and French added to the project, both still empty<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Import <code>default.pot<\/code> on the import page with <strong>Add tags<\/strong> switched on and the tag set to <code>default<\/code>, then <code>validation.pot<\/code> with the tag set to <code>validation<\/code>.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"933\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/04_import_default_pot-1024x933.png\" alt=\"\" class=\"wp-image-8414\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/04_import_default_pot-1024x933.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/04_import_default_pot-300x273.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/04_import_default_pot-767x699.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/04_import_default_pot-1536x1399.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/04_import_default_pot-2048x1866.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Importing<\/em> <em>default.pot, tagging every term it creates<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Both files land in the same project, and those tags are what let one project produce two separate catalogs again on the way out. Two strings appear in both files, and POEditor stores them once with both tags. For that to work, tag all the terms in validation.pot on the second import, not only the new ones, so the shared strings pick up the validation tag as well.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"638\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/05_terms_with_domain_tags-1024x638.png\" alt=\"\" class=\"wp-image-8415\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/05_terms_with_domain_tags-1024x638.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/05_terms_with_domain_tags-300x187.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/05_terms_with_domain_tags-767x478.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/05_terms_with_domain_tags-1536x958.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/05_terms_with_domain_tags-2048x1277.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Each term tagged with the domain it was imported from<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Importing a .pot creates the terms, but the file does not contain translations. As a result, the English language shows 0% translated in POEditor. Use <strong>Copy Terms to Translations<\/strong> to copy the English terms into the English translations so they can be used as a reference while translating into French.&nbsp;<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"569\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/06_copy_terms_menu-1024x569.png\" alt=\"\" class=\"wp-image-8416\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/06_copy_terms_menu-1024x569.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/06_copy_terms_menu-300x167.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/06_copy_terms_menu-767x426.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/06_copy_terms_menu-1536x853.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/06_copy_terms_menu-2048x1137.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Copy Terms to Translations, on the English language<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Now translate. The terms carrying <code>{0}<\/code> and <code>{1}<\/code> need their placeholders kept intact, though they can be moved wherever French word order wants them. If a translation leaves one out, POEditor shows a warning under the translation box.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you would rather review a first draft than translate from scratch, you can pre-fill the French strings with Automatic Translation (Google Translate, DeepL or Azure AI Translator), which is built in, or with AI translation from providers such as OpenAI or Anthropic\u2019s Claude once you add your own API key.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"556\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07_french_translations-1024x556.png\" alt=\"\" class=\"wp-image-8417\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07_french_translations-1024x556.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07_french_translations-300x163.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07_french_translations-767x416.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07_french_translations-1536x834.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/07_french_translations-2048x1111.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Working through the French translations with the English reference alongside<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The plural terms offer a field per form, and French needs three where English needs two: the catalog carries as many forms as the target language has, not as many as the source.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"411\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08_plural_term_one_form-1024x411.png\" alt=\"\" class=\"wp-image-8418\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08_plural_term_one_form-1024x411.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08_plural_term_one_form-300x120.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08_plural_term_one_form-767x308.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08_plural_term_one_form-1536x617.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/08_plural_term_one_form-2048x822.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>A plural term with a field for each of the forms French needs<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">When the translations are done, export French as a <code>.po<\/code> file, once per domain. Filter the export by the <code>default<\/code> tag for the first file and the <code>validation<\/code> tag for the second, because an export with an empty tag filter returns every term in the project and would collapse your two domains back into one.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"890\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/09_export_french_po_tag_default-1024x890.png\" alt=\"\" class=\"wp-image-8419\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/09_export_french_po_tag_default-1024x890.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/09_export_french_po_tag_default-300x261.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/09_export_french_po_tag_default-768x667.png 768w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/09_export_french_po_tag_default-1536x1334.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/09_export_french_po_tag_default-2048x1779.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>Exporting the French catalog, filtered to the<\/em> <em>default<\/em> <em>tag<\/em><\/figcaption><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Loading the French catalog and switching language<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">CakePHP looks for catalogs under <code>resources\/locales\/<\/code>, in a folder named after the locale, with one file per domain. Copy the exported files into that directory.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>resources\/locales\/<br>\u251c\u2500\u2500 default.pot<br>\u251c\u2500\u2500 validation.pot<br>\u2514\u2500\u2500 fr\/<br>&nbsp; &nbsp; \u251c\u2500\u2500 default.po<br>&nbsp; &nbsp; \u2514\u2500\u2500 validation.po<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">There is no compile step<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If you have localized a Django or a plain gettext project, this is where you would reach for <code>msgfmt<\/code> or <code>compilemessages<\/code> to turn the <code>.po<\/code> into a binary <code>.mo<\/code>. CakePHP does not need it: its message loader defaults to the <code>.po<\/code> extension and parses the file directly, so the file POEditor exports is the file the application runs on. Translations are cached aggressively, though, so clear the cache after dropping in a new catalog.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>bin\/cake cache clear_all<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Folder naming follows a fallback, and its order is worth knowing: for an <code>fr_FR<\/code> locale CakePHP looks in <code>resources\/locales\/fr\/<\/code> before <code>resources\/locales\/fr_FR\/<\/code> and loads the first catalog it finds. A single <code>fr<\/code> folder therefore covers every French-speaking region, which is why this tutorial uses one, and a <code>fr_FR<\/code> folder is only read when <code>fr<\/code> has no file for that domain.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Switching at runtime<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>I18n::setLocale()<\/code> sets the locale for the current request. To pick one up from the browser, CakePHP ships <code>LocaleSelectorMiddleware<\/code>, which reads the <code>Accept-Language<\/code> header and matches it against the locales you allow.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em>\/\/ src\/Application.php<\/em><br><strong>use<\/strong> Cake\\I18n\\Middleware\\LocaleSelectorMiddleware;<br><br>$middlewareQueue<br>&nbsp; &nbsp; -&gt;add(<strong>new<\/strong> LocaleSelectorMiddleware(<br>&nbsp; &nbsp; &nbsp; &nbsp; array_keys((array)Configure::read('I18n.locales')),<br>&nbsp; &nbsp; ));<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Passing that list matters. With no list, the middleware never sets a locale at all, and with <code>['*']<\/code> it accepts any header, including languages you have no catalog for. Restricting it to the two languages the app ships means an unrecognised header falls back to <code>App.defaultLocale<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Keying that list on <code>fr<\/code> rather than <code>fr_FR<\/code> is what makes it work for every French reader. <code>Locale::lookup()<\/code> shortens a requested tag but never lengthens it, so fr matches a browser asking for <code>fr<\/code>, <code>fr-FR<\/code> or <code>fr-CA<\/code> alike, while <code>fr_FR<\/code> would match only French (France) and send every other French reader to English.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>Accept-Language<\/code> header is only used as the initial locale selection. To let users choose a language manually, the switcher sends the selection to a controller action, which stores it in the session.&nbsp;<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em>\/\/ src\/Controller\/LocaleController.php<\/em><br><strong>public<\/strong> <strong>function<\/strong> change(string $locale): ?Response<br>{<br>&nbsp; &nbsp; $this-&gt;request-&gt;allowMethod(&#091;'post']);<br><br>&nbsp; &nbsp; <strong>if<\/strong> (!array_key_exists($locale, $this-&gt;availableLocales())) {<br>&nbsp; &nbsp; &nbsp; &nbsp; <strong>throw<\/strong> <strong>new<\/strong> NotFoundException(__('That language is not available.'));<br>&nbsp; &nbsp; }<br><br>&nbsp; &nbsp; $this-&gt;request-&gt;getSession()-&gt;write(<strong>self<\/strong>::LOCALE_SESSION_KEY, $locale);<br>&nbsp; &nbsp; I18n::setLocale($locale);<br><br>&nbsp; &nbsp; <strong>return<\/strong> $this-&gt;redirect($this-&gt;localRedirectTarget());<br>}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Do not name that action <code>set(): Controller::set()<\/code> already exists for passing variables to the view, so an action of that name dies with a signature clash before the page renders.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Read the stored locale in <code>AppController::beforeFilter()<\/code>. This runs after the middleware, so the session value can override the locale selected from the <code>Accept-Language<\/code> header.&nbsp;<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em>\/\/ src\/Controller\/AppController.php<\/em><br><strong>public<\/strong> <strong>function<\/strong> beforeFilter(EventInterface $event): void<br>{<br>&nbsp; &nbsp; <strong>parent<\/strong>::beforeFilter($event);<br><br>&nbsp; &nbsp; $locale = $this-&gt;request-&gt;getSession()-&gt;read(<strong>self<\/strong>::LOCALE_SESSION_KEY);<br><br>&nbsp; &nbsp; <strong>if<\/strong> ($locale &amp;&amp; array_key_exists($locale, $this-&gt;availableLocales())) {<br>&nbsp; &nbsp; &nbsp; &nbsp; I18n::setLocale($locale);<br>&nbsp; &nbsp; }<br>}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Watch the case.<\/strong> <code>LocaleSelectorMiddleware<\/code> resolves the header by calling <code>Locale::lookup()<\/code> with canonicalization on, which returns a lower-cased tag, so a region-coded entry comes back changed: a browser asking for en-US leaves <code>I18n::getLocale()<\/code> reading <code>en_us<\/code>. The translations still load, but a <code>===<\/code> comparison against your configured keys fails and the switcher shows no language as active. Compare with <code>strcasecmp()<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">With the catalog in place and the switcher wired up, the app reads in French, and the count reads <code>Il reste 5 t\u00e2ches<\/code> because <code>__n()<\/code> is choosing the form from the catalog rather than the hand-written <code>if<\/code> we deleted.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"924\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/10_french_list_page-1024x924.png\" alt=\"\" class=\"wp-image-8420\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/10_french_list_page-1024x924.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/10_french_list_page-300x271.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/10_french_list_page-767x692.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/10_french_list_page-1536x1385.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/10_french_list_page-2048x1847.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>The same list page after switching to French, with the switcher in the navbar<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The messages from the <code>validation<\/code> domain arrive with the rest, so a form submitted with a too-short username answers in French as well.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"615\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/11_french_validation_error-1024x615.png\" alt=\"\" class=\"wp-image-8421\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/11_french_validation_error-1024x615.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/11_french_validation_error-300x180.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/11_french_validation_error-767x461.png 767w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/11_french_validation_error-1536x923.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/11_french_validation_error-2048x1231.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>A validation message from the<\/em> <em>validation<\/em> <em>domain, in French<\/em><\/figcaption><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Dates, numbers and percentages<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A French reader also expects <code>16 sept. 2026<\/code> rather than <code>Sep 16, 2026<\/code>, a comma where English puts a decimal point, and a space before a percent sign. The TO-DO app currently uses US-style formats in its templates.&nbsp;<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em>\/\/ templates\/TodoLists\/index.php<\/em><br>created &lt;?= h($list-&gt;created-&gt;format('n\/j\/Y')) <strong>?&gt;<\/strong><\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>Cake\\I18n\\DateTime<\/code> reads the active locale, so switching to <code>i18nFormat()<\/code> or <code>nice()<\/code> gets the formatting for free.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><strong>&lt;?php<\/strong> <em>\/\/ templates\/TodoLists\/index.php<\/em> <strong>?&gt;<\/strong><br><strong>&lt;?<\/strong>= h(__('created {0}', $list-&gt;created-&gt;i18nFormat(&#091;\\IntlDateFormatter::MEDIUM, \\IntlDateFormatter::NONE]))) <strong>?&gt;<\/strong><\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Numbers work the same way through <code>Cake\\I18n\\Number<\/code>, and the progress line mixes a formatted percentage into a translatable sentence.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em>\/\/ src\/Controller\/TodoListsController.php<\/em><br>$progressLabel = __(<br>&nbsp; &nbsp; '{0} of {1} tasks done',<br>&nbsp; &nbsp; Number::toPercentage($todoList-&gt;percent_done, 1),<br>&nbsp; &nbsp; Number::format($totalCount),<br>);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">In English that renders as <code>0.0% of 5 tasks done<\/code>, and once the sentence itself is translated, as <code>0,0 % des 5 t\u00e2ches faites<\/code> in French, with the comma and the space before the percent sign applied by ICU rather than by anything in your code. The dates move the same way, and the timestamps drop AM\/PM for a 24-hour clock.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"472\" src=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/12_french_dashboard-1024x472.png\" alt=\"\" class=\"wp-image-8422\" srcset=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/12_french_dashboard-1024x472.png 1024w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/12_french_dashboard-300x138.png 300w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/12_french_dashboard-768x354.png 768w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/12_french_dashboard-1536x708.png 1536w, https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/12_french_dashboard-2048x945.png 2048w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\"><em>French dates on the dashboard, with both plural forms of the task count<\/em><\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The locale does not carry the time zone. <code>I18n::setLocale()<\/code> changes how a time is written, not which moment it refers to, so an application with users in several countries still needs to set a time zone per user and convert before formatting.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Translating content in the database<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Everything so far has been static text in your source code, and a <code>.po<\/code> file is the right home for it. Content users create is a different problem: the TO-DO app\u2019s lists and tasks are typed by the account that owns them, so there is nothing to translate.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Editorial content is where this changes. Category names, a product catalogue or help text stored in a table are written by you, shown to everyone, and need a version per language. CakePHP handles them with the Translate behavior, attached to a table rather than threaded through your queries.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code><em>\/\/ src\/Model\/Table\/CategoriesTable.php<\/em><br>$this-&gt;addBehavior('Translate', &#091;'fields' =&gt; &#091;'name', 'description']]);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A find then returns the translation for whichever locale is active, and writing one means setting <code>_locale<\/code> on the entity before saving. By default the translations live in a companion <code>categories_translations<\/code> table, and the <a href=\"https:\/\/book.cakephp.org\/5\/en\/orm\/behaviors\/translate.html\" rel=\"nofollow\">Translate behavior docs<\/a> cover the shared-table alternative. Use it for content managed by your application rather than content entered by individual users.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Keeping the catalogs in sync<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The catalogs need to be updated when the application changes. You can use the same extraction command to generate an updated <code>.pot<\/code> file.&nbsp;<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>bin\/cake i18n extract --paths src,templates --output resources\/locales --extract-core no --overwrite<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Re-running it picks up new strings and drops the ones you have deleted. Import the refreshed <code>.pot<\/code> into POEditor and the new terms appear untranslated while the existing ones keep their translations, so a translator only sees what changed. Terms you removed from the code stay in the POEditor project until you delete them, though. Tagging the obsolete terms on import makes them easy to find.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A message is identified by its text, so changing <code>Give the list a name<\/code>. to Please give the list a name. does not edit a term, it creates a new one and orphans the old. Every translation reverts to untranslated, which is correct behaviour for a changed sentence, but it means a wording tweak on release day puts every language back into the queue.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you update the catalogs regularly, consider automating this process. POEditor has an <a href=\"\/docs\/api\">API<\/a> and integrations that let you push a <code>.pot<\/code> and pull finished <code>.po<\/code> files as part of a build, so the catalogs follow the code rather than trailing a release behind it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A simpler safety net is to run the extract command in CI and fail the build when the committed .pot files are out of date. Every run rewrites the POT-Creation-Date header and the #: line references shift whenever code moves, so tell the diff to ignore those lines, for example with git diff &#8211;exit-code -I &#8216;^#:&#8217; -I &#8216;^&#8221;POT-Creation-Date&#8217; resources\/locales. That catches a marked string nobody extracted before it ships untranslated. Note that git diff only compares files the repository already tracks, so commit a new domain\u2019s .pot the first time it appears, or the check will never see it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Wrapping up<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The TO-DO app now marks its strings with <code>__()<\/code> and <code>__n()<\/code>, keeps its validation messages in their own domain, extracts to a <code>.pot<\/code> with a single console command, takes its French from a real translation workflow rather than a hand-edited file, and switches between English and French from the navbar. Because CakePHP reads <code>.po<\/code> files directly, shipping a new language means adding a folder and clearing a cache, with no compile step anywhere in the deploy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"\/register\/\">Create a free POEditor account<\/a> and import your <code>.pot<\/code> file to see how your own catalog looks with a translation interface around it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you are localizing something else in the same stack, these guides cover the same ground for other frameworks:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"\/blog\/django-i18n\/\">Django i18n: How to translate and localize your app<\/a><\/li>\n\n\n\n<li><a href=\"\/blog\/python-gettext-localization\/\">Python gettext localization: How to translate a Python app<\/a><\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently asked questions<\/h2>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary><strong>Does CakePHP need .mo files, or will .po do?<\/strong><\/summary>\n<p class=\"wp-block-paragraph\">.po is all you need. CakePHP\u2019s message loader defaults to the po extension and parses the file directly, so the catalog your translator exports is the one the application runs on, with no msgfmt step in your deploy. A parser for the compiled .mo format ships with the framework too, but nothing looks for those files unless you register a loader with the mo extension yourself.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary><strong>Where do CakePHP translation files go?<\/strong><\/summary>\n<p class=\"wp-block-paragraph\">Under resources\/locales\/, in a folder named after the locale, one file per domain: fr\/default.po for everything marked with __(), fr\/validation.po for a domain created with __d(&#8216;validation&#8217;, \u2026). Watch the precedence, which runs the opposite way to most expectations: for an fr_FR locale CakePHP checks resources\/locales\/fr\/ first and loads the first catalog it finds, so a language-only folder shadows a regional one.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary><strong>What is the difference between __() and __d()?<\/strong><\/summary>\n<p class=\"wp-block-paragraph\">Only which catalog the string comes from. __() reads the default domain, where everything lands unless you say otherwise, and __d() takes a domain name as its first argument and reads that file instead. Splitting a domain off pays when a group of strings has its own audience or release rhythm, and costs one more file to extract, import and keep in sync.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary><strong>Should I use __n() or ICU plural syntax?<\/strong><\/summary>\n<p class=\"wp-block-paragraph\">__n() is the recommended option for most work: it takes a singular, a plural, and the count, bin\/cake i18n extract writes it into the catalog as a native plural entry, and translation tools give it a field per form. ICU syntax packs every form into one message, but separate plural forms are easier to work with in the translation workflow. Either way, CakePHP picks the form from its built-in plural rules for the active locale, so the catalog needs as many forms as that locale uses, in the matching order.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary><strong>Why is bin\/cake i18n extract missing some of my strings?<\/strong><\/summary>\n<p class=\"wp-block-paragraph\">Almost always because the string is not a literal. Extraction parses your source instead of running it, so __($message) with the sentence built at runtime is invisible, as is anything concatenated before the translation call. Another common cause is that the command did not scan the directory containing the string: run it with &#8211;paths src,templates, and reach for &#8211;plugin for strings inside a plugin.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary><strong>Why is my translation not showing up after I add the .po file?<\/strong><\/summary>\n<p class=\"wp-block-paragraph\">Start by clearing the cache, where this usually ends: run bin\/cake cache clear_all and reload. If the text is still English, check the file sits in a folder CakePHP searches, remembering that fr\/ shadows fr_FR\/. If only some strings are missing, compare them character for character with the msgid, because a message is identified by its source text and a changed full stop is a different entry.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary><strong>How do I change the language per user instead of per browser?<\/strong><\/summary>\n<p class=\"wp-block-paragraph\">LocaleSelectorMiddleware reads the Accept-Language header, which is a first guess and nothing more. To let people choose, write the locale they picked to the session from a small controller action and apply it in AppController::beforeFilter(), which runs after the middleware and therefore wins. If you compare the active locale against your own list, note that Locale::lookup() canonicalizes to lower case, so en-US arrives as en_us.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary><strong>Does CakePHP translate its own error and validation messages?<\/strong><\/summary>\n<p class=\"wp-block-paragraph\">Its user-facing strings are marked in a domain called cake, but the translations are not bundled with the framework. They live in the separate cakephp\/localized plugin, installed with Composer and loaded like any other; it also carries locale-specific validation classes for things like postcodes and phone numbers. Your own messages are never covered by it, whichever domain they sit in.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary><strong>How do I translate database content in CakePHP?<\/strong><\/summary>\n<p class=\"wp-block-paragraph\">With the Translate behavior, attached to the table rather than threaded through your queries. It stores translations in a shadow table named after the original, so categories gets categories_translations; a find returns whichever locale is active, and writing means setting _locale on the entity before saving. Use it for content you author and everyone sees, not for rows a user typed themselves.<\/p>\n<\/details>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary><strong>How do I get a CakePHP .pot file into POEditor and back?<\/strong><\/summary>\n<p class=\"wp-block-paragraph\">Import it as it is. bin\/cake i18n extract writes standard Gettext, so POEditor reads the {0} placeholders, the plural entries and the source references without conversion, and exports French back as a .po file that drops straight into resources\/locales\/fr\/. Tag each import with its domain name so one project can export default.po and validation.po separately, and use the API or a Git integration once the round trip becomes routine.<\/p>\n<\/details>\n\n\n<div class=\"call-action my-4 d-flex justify-content-between align-items-md-center gap-4 flex-column flex-lg-row\"><div><h3 class=\"fs-4\">Ready to power up localization?<\/h3><span class=\"fs-6\">Subscribe to the POEditor platform today!<\/span><\/div><a class=\"btn btn-b-primary d-flex align-items-center justify-content-center px-4 py-3 flex-shrink-0\" \n\t\t\t\t\thref=\"https:\/\/poeditor.com\/pricing\/?utm_source=blog&#038;utm_medium=btn&#038;utm_campaign=cta_pricing\">See pricing<\/a><\/div>\n","protected":false},"excerpt":{"rendered":"<p>CakePHP relies on conventions to keep configuration simple. For example, if you name a table todo_lists, the framework expects the related table class, controller and template folder to follow the same naming pattern. This makes it easier to navigate an unfamiliar CakePHP project.&nbsp; These conventions do not cover the language used by the application. If [&hellip;]<\/p>\n","protected":false},"author":6,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[],"class_list":["post-8397","post","type-post","status-publish","format-standard","hentry","category-tutorials"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.5 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>CakePHP localization: How to translate a CakePHP app with i18n - POEditor Blog<\/title>\n<meta name=\"description\" content=\"Check out our tutorial on CakePHP localization with POEditor: extract strings, manage translations, and sync multilingual PO files with ease.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/poeditor.com\/blog\/cakephp-localization\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"CakePHP localization: How to translate a CakePHP app with i18n - POEditor Blog\" \/>\n<meta property=\"og:description\" content=\"Check out our tutorial on CakePHP localization with POEditor: extract strings, manage translations, and sync multilingual PO files with ease.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/poeditor.com\/blog\/cakephp-localization\/\" \/>\n<meta property=\"og:site_name\" content=\"POEditor Blog\" \/>\n<meta property=\"article:publisher\" content=\"https:\/\/www.facebook.com\/POEditor\" \/>\n<meta property=\"article:published_time\" content=\"2026-09-24T07:58:36+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-09-24T08:07:33+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/CakePHP-localization.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1757\" \/>\n\t<meta property=\"og:image:height\" content=\"992\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"POEditor\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:creator\" content=\"@poeditor\" \/>\n<meta name=\"twitter:site\" content=\"@poeditor\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"POEditor\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"20 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/cakephp-localization\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/cakephp-localization\\\/\"},\"author\":{\"name\":\"POEditor\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#\\\/schema\\\/person\\\/db77eb2d09539eaac83dcd8f2af06b17\"},\"headline\":\"CakePHP localization: How to translate a CakePHP app with i18n\",\"datePublished\":\"2026-09-24T07:58:36+00:00\",\"dateModified\":\"2026-09-24T08:07:33+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/cakephp-localization\\\/\"},\"wordCount\":3974,\"publisher\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#organization\"},\"image\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/cakephp-localization\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/CakePHP-localization-1024x578.png\",\"articleSection\":[\"Tutorials\"],\"inLanguage\":\"en-US\"},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/cakephp-localization\\\/\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/cakephp-localization\\\/\",\"name\":\"CakePHP localization: How to translate a CakePHP app with i18n - POEditor Blog\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/cakephp-localization\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/cakephp-localization\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/CakePHP-localization-1024x578.png\",\"datePublished\":\"2026-09-24T07:58:36+00:00\",\"dateModified\":\"2026-09-24T08:07:33+00:00\",\"description\":\"Check out our tutorial on CakePHP localization with POEditor: extract strings, manage translations, and sync multilingual PO files with ease.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/cakephp-localization\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/poeditor.com\\\/blog\\\/cakephp-localization\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/cakephp-localization\\\/#primaryimage\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/CakePHP-localization.png\",\"contentUrl\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/CakePHP-localization.png\",\"width\":1757,\"height\":992,\"caption\":\"CakePHP localization\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/cakephp-localization\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"CakePHP localization: How to translate a CakePHP app with i18n\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/\",\"name\":\"POEditor Blog\",\"description\":\"All about translation and localization management\",\"publisher\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#organization\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#organization\",\"name\":\"POEditor\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#\\\/schema\\\/logo\\\/image\\\/\",\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2019\\\/11\\\/logo_head_512_transparent.png\",\"contentUrl\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/wp-content\\\/uploads\\\/2019\\\/11\\\/logo_head_512_transparent.png\",\"width\":512,\"height\":512,\"caption\":\"POEditor\"},\"image\":{\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#\\\/schema\\\/logo\\\/image\\\/\"},\"sameAs\":[\"https:\\\/\\\/www.facebook.com\\\/POEditor\",\"https:\\\/\\\/x.com\\\/poeditor\",\"https:\\\/\\\/www.linkedin.com\\\/company\\\/poeditor\\\/\",\"https:\\\/\\\/www.youtube.com\\\/channel\\\/UCXAk1u8N49VRMAqNneENCFA\"]},{\"@type\":\"Person\",\"@id\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/#\\\/schema\\\/person\\\/db77eb2d09539eaac83dcd8f2af06b17\",\"name\":\"POEditor\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g\",\"url\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g\",\"contentUrl\":\"https:\\\/\\\/secure.gravatar.com\\\/avatar\\\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g\",\"caption\":\"POEditor\"},\"url\":\"https:\\\/\\\/poeditor.com\\\/blog\\\/author\\\/poeditor\\\/\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"CakePHP localization: How to translate a CakePHP app with i18n - POEditor Blog","description":"Check out our tutorial on CakePHP localization with POEditor: extract strings, manage translations, and sync multilingual PO files with ease.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/poeditor.com\/blog\/cakephp-localization\/","og_locale":"en_US","og_type":"article","og_title":"CakePHP localization: How to translate a CakePHP app with i18n - POEditor Blog","og_description":"Check out our tutorial on CakePHP localization with POEditor: extract strings, manage translations, and sync multilingual PO files with ease.","og_url":"https:\/\/poeditor.com\/blog\/cakephp-localization\/","og_site_name":"POEditor Blog","article_publisher":"https:\/\/www.facebook.com\/POEditor","article_published_time":"2026-09-24T07:58:36+00:00","article_modified_time":"2026-09-24T08:07:33+00:00","og_image":[{"width":1757,"height":992,"url":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/CakePHP-localization.png","type":"image\/png"}],"author":"POEditor","twitter_card":"summary_large_image","twitter_creator":"@poeditor","twitter_site":"@poeditor","twitter_misc":{"Written by":"POEditor","Est. reading time":"20 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/poeditor.com\/blog\/cakephp-localization\/#article","isPartOf":{"@id":"https:\/\/poeditor.com\/blog\/cakephp-localization\/"},"author":{"name":"POEditor","@id":"https:\/\/poeditor.com\/blog\/#\/schema\/person\/db77eb2d09539eaac83dcd8f2af06b17"},"headline":"CakePHP localization: How to translate a CakePHP app with i18n","datePublished":"2026-09-24T07:58:36+00:00","dateModified":"2026-09-24T08:07:33+00:00","mainEntityOfPage":{"@id":"https:\/\/poeditor.com\/blog\/cakephp-localization\/"},"wordCount":3974,"publisher":{"@id":"https:\/\/poeditor.com\/blog\/#organization"},"image":{"@id":"https:\/\/poeditor.com\/blog\/cakephp-localization\/#primaryimage"},"thumbnailUrl":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/CakePHP-localization-1024x578.png","articleSection":["Tutorials"],"inLanguage":"en-US"},{"@type":"WebPage","@id":"https:\/\/poeditor.com\/blog\/cakephp-localization\/","url":"https:\/\/poeditor.com\/blog\/cakephp-localization\/","name":"CakePHP localization: How to translate a CakePHP app with i18n - POEditor Blog","isPartOf":{"@id":"https:\/\/poeditor.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/poeditor.com\/blog\/cakephp-localization\/#primaryimage"},"image":{"@id":"https:\/\/poeditor.com\/blog\/cakephp-localization\/#primaryimage"},"thumbnailUrl":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/CakePHP-localization-1024x578.png","datePublished":"2026-09-24T07:58:36+00:00","dateModified":"2026-09-24T08:07:33+00:00","description":"Check out our tutorial on CakePHP localization with POEditor: extract strings, manage translations, and sync multilingual PO files with ease.","breadcrumb":{"@id":"https:\/\/poeditor.com\/blog\/cakephp-localization\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/poeditor.com\/blog\/cakephp-localization\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/poeditor.com\/blog\/cakephp-localization\/#primaryimage","url":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/CakePHP-localization.png","contentUrl":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2026\/09\/CakePHP-localization.png","width":1757,"height":992,"caption":"CakePHP localization"},{"@type":"BreadcrumbList","@id":"https:\/\/poeditor.com\/blog\/cakephp-localization\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/poeditor.com\/blog\/"},{"@type":"ListItem","position":2,"name":"CakePHP localization: How to translate a CakePHP app with i18n"}]},{"@type":"WebSite","@id":"https:\/\/poeditor.com\/blog\/#website","url":"https:\/\/poeditor.com\/blog\/","name":"POEditor Blog","description":"All about translation and localization management","publisher":{"@id":"https:\/\/poeditor.com\/blog\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/poeditor.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/poeditor.com\/blog\/#organization","name":"POEditor","url":"https:\/\/poeditor.com\/blog\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/poeditor.com\/blog\/#\/schema\/logo\/image\/","url":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2019\/11\/logo_head_512_transparent.png","contentUrl":"https:\/\/poeditor.com\/blog\/wp-content\/uploads\/2019\/11\/logo_head_512_transparent.png","width":512,"height":512,"caption":"POEditor"},"image":{"@id":"https:\/\/poeditor.com\/blog\/#\/schema\/logo\/image\/"},"sameAs":["https:\/\/www.facebook.com\/POEditor","https:\/\/x.com\/poeditor","https:\/\/www.linkedin.com\/company\/poeditor\/","https:\/\/www.youtube.com\/channel\/UCXAk1u8N49VRMAqNneENCFA"]},{"@type":"Person","@id":"https:\/\/poeditor.com\/blog\/#\/schema\/person\/db77eb2d09539eaac83dcd8f2af06b17","name":"POEditor","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/secure.gravatar.com\/avatar\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g","url":"https:\/\/secure.gravatar.com\/avatar\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/95bca2c465fe6134f210cb2f2fbed4f71bf37833fb285624320e808c865695c1?s=96&d=mm&r=g","caption":"POEditor"},"url":"https:\/\/poeditor.com\/blog\/author\/poeditor\/"}]}},"_links":{"self":[{"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/posts\/8397","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/users\/6"}],"replies":[{"embeddable":true,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/comments?post=8397"}],"version-history":[{"count":6,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/posts\/8397\/revisions"}],"predecessor-version":[{"id":8431,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/posts\/8397\/revisions\/8431"}],"wp:attachment":[{"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/media?parent=8397"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/categories?post=8397"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/poeditor.com\/blog\/wp-json\/wp\/v2\/tags?post=8397"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}