This course is still being released! Check back later for more chapters.
Translation Logic
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
We've done a lot of setup work to get to this point, but it's time for the main event: translating our objects. Let's dive in!
Our database has been loaded with these fixtures. Over in our browser, click on the first article. This is the English version. When we switch to French, we should see those French fixture values... But... we have an error...
"Too few arguments to... TranslatedObject::__construct()". Oh yeah, we
added the translations argument, but haven't updated our ObjectTranslator
yet. Perfect, that's our goal!
translationsFor
Method
In object-translation-bundle/src
, open ObjectTranslator
. In the translate()
method, where we're creating a new TranslatedObject
, we need to add a
second argument.
Stub out a method for this: $this->translationsFor()
. Pass $object
and
$locale
:
// ... lines 1 - 6 | |
final class ObjectTranslator | |
{ | |
// ... lines 9 - 22 | |
public function translate(object $object): object | |
{ | |
// ... lines 25 - 30 | |
return new TranslatedObject($object, $this->translationsFor($object, $locale)); | |
} | |
// ... lines 33 - 36 | |
} |
This method doesn't exist, so generate it with PhpStorm. Cool! It knew the parameter
type-hints! This method needs to return the translated values as an array, keyed
by property names. So, set the return type to array
:
// ... lines 1 - 6 | |
final class ObjectTranslator | |
{ | |
// ... lines 9 - 33 | |
private function translationsFor(object $object, string $locale): array | |
{ | |
} | |
} |
Translatable "Name"
First, we need to determine if this object is translatable, and if so, get
the $name
property from the Translatable
attribute.
We can use PHP's reflection system to do this. Create a ReflectionClass
for the passed object: $class = new \ReflectionClass($object)
:
// ... lines 1 - 7 | |
final class ObjectTranslator | |
{ | |
// ... lines 10 - 34 | |
private function translationsFor(object $object, string $locale): array | |
// ... line 36 | |
$class = new \ReflectionClass($object); | |
// ... lines 38 - 42 | |
} | |
} |
Now, we need to fetch the attribute: $type = $class->getAttributes()
. By default,
this will return all attributes used on this class - since we only want
Translatable
attributes, pass this as the first argument: Translatable::class
.
getAttributes()
returns an array of ReflectionAttribute
objects. Since we
only allow one Translatable
attribute per class, we can safely get the first element:
[0]
. This class might not have the attribute, so use the null-safe operator, ?->
, and call
newInstance()
. This instantiates our Translatable
attribute class. Now that we have the object,
grab the name with ->name
. Because of the null-safe check, this whole thing might
be null, so use the null coalescing operator, ??
, and default to null
:
// ... lines 1 - 7 | |
final class ObjectTranslator | |
{ | |
// ... lines 10 - 34 | |
private function translationsFor(object $object, string $locale): array | |
{ | |
// ... line 37 | |
$type = $class->getAttributes(Translatable::class)[0]?->newInstance()->name ?? null; | |
// ... lines 39 - 42 | |
} | |
} |
Ensure we have a type with if (!$type)
. Inside,
throw new \LogicException(sprintf('Class "%s" is not translatable.', $object::class));
:
// ... lines 1 - 7 | |
final class ObjectTranslator | |
{ | |
// ... lines 10 - 34 | |
private function translationsFor(object $object, string $locale): array | |
{ | |
// ... lines 37 - 39 | |
if (!$type) { | |
throw new \LogicException(sprintf('Class "%s" is not translatable.', $object::class)); | |
} | |
} | |
} |
ManagerRegistry
Service
Now we need to fetch the translations from Doctrine. In our constructor, inject
another service: private ManagerRegistry $doctrine
:
// ... lines 1 - 9 | |
final class ObjectTranslator | |
{ | |
public function __construct( | |
// ... lines 13 - 15 | |
private ManagerRegistry $doctrine, | |
) { | |
} | |
// ... lines 19 - 61 | |
} |
This is the "one
Doctrine service to rule them all". You might be used to using the
Entity Manager or em. The ManagerRegistry
is a level above. You can get
pretty complex with Doctrine, multiple databases and multiple entity, or object
managers. The ManagerRegistry
brings them all together. Using this, we can
support all this complexity.
In our bundle's services.php
, we need to wire this up as a fourth argument.
I know the ManagerRegistry
is autowireable, so use our handy trick to find the
service ID. Jump over to the terminal and run:
symfony console debug:autowiring ManagerRegistry
Here we go: alias:doctrine
- doctrine
is the service ID - that's a short one!
Copy that and back in services.php
, define it as the fourth argument:
service()
, paste:
// ... lines 1 - 6 | |
return static function (ContainerConfigurator $container): void { | |
$container->services() | |
->set('symfonycasts.object_translator', ObjectTranslator::class) | |
->args([ | |
// ... lines 11 - 13 | |
service('doctrine'), | |
]) | |
// ... lines 16 - 17 | |
; | |
}; |
Perfect!
Fetching Translations
Doctrine's now available in our ObjectTranslator
, so, down in our new translationsFor()
method, add $translations = $this->doctrine
. To get the repository for
our Translation
class, write ->getRepository()
. This was the class we
configured and injected previously, so write $this->translationClass
.
Now that we have the repository, check out the methods we have available.
Look familiar? findBy()
is what we want. We'll pass an array of criteria,
or filters. First? 'locale' => $locale
. Second, the object type:
'objectType' => $type
. Finally, object ID: 'objectId' =>
... hmm... what
should we use here? If we look at one of our app's entities, we can see they
all have a getId()
method. For now, let's just use that: $object->getId()
:
// ... lines 1 - 9 | |
final class ObjectTranslator | |
{ | |
// ... lines 12 - 37 | |
private function translationsFor(object $object, string $locale): array | |
{ | |
// ... lines 40 - 47 | |
$translations = $this->doctrine->getRepository($this->translationClass)->findBy([ | |
'locale' => $locale, | |
'objectType' => $type, | |
'objectId' => $object->getId(), | |
]); | |
// ... lines 53 - 60 | |
} | |
} |
Make sure we're on the right track by dd($translations)
, jump over to the browser
and refresh.
Exactly what we expect! An array of two Translation
objects! But... this needs
to be normalized into a simple array of key-value pairs.
Normalizing Translations
Back in our code, remove the dd()
. To help with the next step, above $translations
,
add a docblock comment with @var $translations
. For the type, Translation
- import
the one from our bundle. Suffix with []
to indicate it's an array of these objects:
// ... lines 1 - 9 | |
final class ObjectTranslator | |
{ | |
// ... lines 12 - 37 | |
private function translationsFor(object $object, string $locale): array | |
{ | |
// ... lines 40 - 46 | |
/** @var Translation[] $translations */ | |
$translations = $this->doctrine->getRepository($this->translationClass)->findBy([ | |
// ... lines 49 - 51 | |
]); | |
// ... lines 53 - 60 | |
} | |
} |
Create a new array variable for our normalized translations: $translationValues = []
:
// ... lines 1 - 9 | |
final class ObjectTranslator | |
{ | |
// ... lines 12 - 37 | |
private function translationsFor(object $object, string $locale): array | |
{ | |
// ... lines 40 - 53 | |
$translationValues = []; | |
// ... lines 55 - 60 | |
} | |
} |
Loop over the translation entities with foreach ($translations as $translation)
. Inside,
set the key-value pair: $translationValues[$translation->field] = $translation->value
:
// ... lines 1 - 9 | |
final class ObjectTranslator | |
{ | |
// ... lines 12 - 37 | |
private function translationsFor(object $object, string $locale): array | |
{ | |
// ... lines 40 - 55 | |
foreach ($translations as $translation) { | |
$translationValues[$translation->field] = $translation->value; | |
} | |
// ... lines 59 - 60 | |
} | |
} |
dd($translationValues)
to check our work. Jump back to the browser and refresh.
Beautiful! This is exactly the format we need!
Remove the dd()
and return $translationValues
:
// ... lines 1 - 9 | |
final class ObjectTranslator | |
{ | |
// ... lines 12 - 37 | |
private function translationsFor(object $object, string $locale): array | |
{ | |
// ... lines 40 - 59 | |
return $translationValues; | |
} | |
} |
Moment of truth! Back in the browser, refresh the page. Boom! "French title" and "French content".
Our translations are being successfully fetched and displayed. We can switch between English and French for this article. Brilliant!
You might have thought this already, but assuming a user's entity has a getId()
method
isn't a great idea... Let's explore a more robust solution next.
Please Gnomes Deliver moar!!!!
Awesome Tutorial as always!