Associations - Linking Tables Together
Defining relations between different objects in your application should bea natural process. For example, an article may have many comments, and belongto an author. Authors may have many articles and comments. CakePHP makesmanaging these associations easy. The four association types in CakePHP are:hasOne, hasMany, belongsTo, and belongsToMany.
Relationship | Association Type | Example |
---|---|---|
one to one | hasOne | A user has one profile. |
one to many | hasMany | A user can have multiple articles. |
many to one | belongsTo | Many articles belong to a user. |
many to many | belongsToMany | Tags belong to many articles. |
Associations are defined during the initialize()
method of your tableobject. Methods matching the association type allow you to define theassociations in your application. For example if we wanted to define a belongsToassociation in our ArticlesTable:
- namespace App\Model\Table;
- use Cake\ORM\Table;
- class ArticlesTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->belongsTo('Authors');
- }
- }
The simplest form of any association setup takes the table alias you want toassociate with. By default all of the details of an association will use theCakePHP conventions. If you want to customize how your associations are handledyou can modify them with setters:
- class ArticlesTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->belongsTo('Authors', [
- 'className' => 'Publishing.Authors'
- ])
- ->setForeignKey('authorid')
- ->setProperty('person');
- }
- }
You can also use arrays to customize your associations:
- $this->belongsTo('Authors', [
- 'className' => 'Publishing.Authors',
- 'foreignKey' => 'authorid',
- 'propertyName' => 'person'
- ]);
However, arrays do not offer the typehinting and autocomplete benefits that the fluent interface does.
The same table can be used multiple times to define different types ofassociations. For example consider a case where you want to separateapproved comments and those that have not been moderated yet:
- class ArticlesTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->hasMany('Comments')
- ->setFinder('approved');
- $this->hasMany('UnapprovedComments', [
- 'className' => 'Comments'
- ])
- ->setFinder('unapproved')
- ->setProperty('unapproved_comments');
- }
- }
As you can see, by specifying the className
key, it is possible to use thesame table as different associations for the same table. You can even createself-associated tables to create parent-child relationships:
- class CategoriesTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->hasMany('SubCategories', [
- 'className' => 'Categories'
- ]);
- $this->belongsTo('ParentCategories', [
- 'className' => 'Categories'
- ]);
- }
- }
You can also setup associations in mass by making a single call toTable::addAssociations()
which accepts an array containing a set oftable names indexed by association type as an argument:
- class PostsTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->addAssociations([
- 'belongsTo' => [
- 'Users' => ['className' => 'App\Model\Table\UsersTable']
- ],
- 'hasMany' => ['Comments'],
- 'belongsToMany' => ['Tags']
- ]);
- }
- }
Each association type accepts multiple associations where the keys are thealiases, and the values are association config data. If numeric keys are usedthe values will be treated as association aliases.
HasOne Associations
Let’s set up a Users table with a hasOne relationship to the Addresses table.
First, your database tables need to be keyed correctly. For a hasOnerelationship to work, one table has to contain a foreign key that points to arecord in the other table. In this case, the Addresses table will contain a fieldcalled ‘user_id’. The basic pattern is:
hasOne: the other model contains the foreign key.
Relation | Schema |
---|---|
Users hasOne Addresses | addresses.user_id |
Doctors hasOne Mentors | mentors.doctor_id |
Note
It is not mandatory to follow CakePHP conventions, you can override the nameof any foreignKey
in your associations definitions. Nevertheless, stickingto conventions will make your code less repetitive, easier to read and tomaintain.
Once you create the UsersTable
and AddressesTable
classes, you can makethe association with the following code:
- class UsersTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->hasOne('Addresses');
- }
- }
If you need more control, you can define your associations using the setters.For example, you might want to limit the association to include only certainrecords:
- class UsersTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->hasOne('Addresses')
- ->setName('Addresses')
- ->setFinder('primary')
- ->setDependent(true);
- }
- }
If you want to break different addresses into multiple associations, you can do something like:
- class UsersTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->hasOne('HomeAddress', [
- 'className' => 'Addresses'
- ])
- ->setProperty('home_address')
- ->setConditions(['HomeAddress.label' => 'Home'])
- ->setDependent(true);
- $this->hasOne('WorkAddress', [
- 'className' => 'Addresses'
- ])
- ->setProperty('work_address')
- ->setConditions(['WorkAddress.label' => 'Work'])
- ->setDependent(true);
- }
- }
Note
If a column is shared by multiple hasOne associations, you must qualify it with the association alias.In the above example, the ‘label’ column is qualified with the ‘HomeAddress’ and ‘WorkAddress’ aliases.
Possible keys for hasOne association arrays include:
- className: The class name of the other table. This is the same name usedwhen getting an instance of the table. In the ‘Users hasOne Addresses’ example,it should be ‘Addresses’. The default value is the name of the association.
- foreignKey: The name of the foreign key column in the other table. Thedefault value is the underscored, singular name of the current model,suffixed with ‘_id’ such as ‘user_id’ in the above example.
- bindingKey: The name of the column in the current table used to match the
foreignKey
. The default value is the primary key of the current tablesuch as ‘id’ of Users in the above example. - conditions: An array of find() compatible conditions such as
['Addresses.primary' => true]
- joinType: The type of the join used in the SQL query. Accepted values are‘LEFT’ and ‘INNER’. You can use ‘INNER’ to get results only where theassociation is set. The default value is ‘LEFT’.
- dependent: When the dependent key is set to
true
, and an entity isdeleted, the associated model records are also deleted. In this case we set ittotrue
so that deleting a User will also delete her associated Address. - cascadeCallbacks: When this and dependent are
true
, cascadeddeletes will load and delete entities so that callbacks are properlytriggered. Whenfalse
,deleteAll()
is used to remove associated dataand no callbacks are triggered. - propertyName: The property name that should be filled with data from theassociated table into the source table results. By default this is theunderscored & singular name of the association so
address
in our example. - strategy: The query strategy used to load matching record from the other table.Accepted values are ‘join’ and ‘select’. Using ‘select’ will generate a separate query.The default is ‘join’.
- finder: The finder method to use when loading associated records.
Once this association has been defined, find operations on the Users table cancontain the Address record if it exists:
- // In a controller or table method.
- $query = $users->find('all')->contain(['Addresses']);
- foreach ($query as $user) {
- echo $user->address->street;
- }
The above would emit SQL that is similar to:
- SELECT * FROM users INNER JOIN addresses ON addresses.user_id = users.id;
BelongsTo Associations
Now that we have Address data access from the User table, let’s definea belongsTo association in the Addresses table in order to get access to relatedUser data. The belongsTo association is a natural complement to the hasOne andhasMany associations - it allows us to see related data from the otherdirection.
When keying your database tables for a belongsTo relationship, follow thisconvention:
belongsTo: the current model contains the foreign key.
Relation | Schema |
---|---|
Addresses belongsTo Users | addresses.user_id |
Mentors belongsTo Doctors | mentors.doctor_id |
Tip
If a table contains a foreign key, it belongs to the other table.
We can define the belongsTo association in our Addresses table as follows:
- class AddressesTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->belongsTo('Users');
- }
- }
We can also define a more specific relationship using the setters:
- class AddressesTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->belongsTo('Users')
- ->setForeignKey('user_id')
- ->setJoinType('INNER');
- }
- }
Possible keys for belongsTo association arrays include:
- className: The class name of the other table. This is the same name usedwhen getting an instance of the table. In the ‘Addresses belongsTo Users’ example,it should be ‘Users’. The default value is the name of the association.
- foreignKey: The name of the foreign key column in the current table. Thedefault value is the underscored, singular name of the other model,suffixed with ‘_id’ such as ‘user_id’ in the above example.
- bindingKey: The name of the column in the other table used to match the
foreignKey
. The default value is the primary key of the other tablesuch as ‘id’ of Users in the above example. - conditions: An array of find() compatible conditions or SQL strings suchas
['Users.active' => true]
- joinType: The type of the join used in the SQL query. Accepted values are‘LEFT’ and ‘INNER’. You can use ‘INNER’ to get results only where theassociation is set. The default value is ‘LEFT’.
- propertyName: The property name that should be filled with data from theassociated table into the source table results. By default this is theunderscored & singular name of the association so
user
in our example. - strategy: The query strategy used to load matching record from the other table.Accepted values are ‘join’ and ‘select’. Using ‘select’ will generate a separate query.The default is ‘join’.
- finder: The finder method to use when loading associated records.
Once this association has been defined, find operations on the Addresses table cancontain the User record if it exists:
- // In a controller or table method.
- $query = $addresses->find('all')->contain(['Users']);
- foreach ($query as $address) {
- echo $address->user->username;
- }
The above would output SQL similar to:
- SELECT * FROM addresses LEFT JOIN users ON addresses.user_id = users.id;
HasMany Associations
An example of a hasMany association is “Articles hasMany Comments”. Defining thisassociation will allow us to fetch an article’s comments when the article isloaded.
When creating your database tables for a hasMany relationship, follow thisconvention:
hasMany: the other model contains the foreign key.
Relation | Schema |
---|---|
Articles hasMany Comments | Comments.article_id |
Products hasMany Options | Options.product_id |
Doctors hasMany Patients | Patients.doctor_id |
We can define the hasMany association in our Articles model as follows:
- class ArticlesTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->hasMany('Comments');
- }
- }
We can also define a more specific relationship using the setters:
- class ArticlesTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->hasMany('Comments')
- ->setForeignKey('article_id')
- ->setDependent(true);
- }
- }
Sometimes you may want to configure composite keys in your associations:
- // Within ArticlesTable::initialize() call
- $this->hasMany('Comments')
- ->setForeignKey([
- 'article_id',
- 'article_hash'
- ]);
Relying on the example above, we have passed an array containing the desiredcomposite keys to setForeignKey()
. By default the bindingKey
would beautomatically defined as id
and hash
respectively, but let’s assume thatyou need to specify different binding fields than the defaults. You can setup itmanually with setBindingKey()
:
- // Within ArticlesTable::initialize() call
- $this->hasMany('Comments')
- ->setForeignKey([
- 'article_id',
- 'article_hash'
- ])
- ->setBindingKey([
- 'whatever_id',
- 'whatever_hash'
- ]);
Like hasOne associations, foreignKey
is in the other (Comments)table and bindingKey
is in the current (Articles) table.
Possible keys for hasMany association arrays include:
- className: The class name of the other table. This is the same name usedwhen getting an instance of the table. In the ‘Articles hasMany Comments’ example,it should be ‘Comments’. The default value is the name of the association.
- foreignKey: The name of the foreign key column in the other table. Thedefault value is the underscored, singular name of the current model,suffixed with ‘_id’ such as ‘article_id’ in the above example.
- bindingKey: The name of the column in the current table used to match the
foreignKey
. The default value is the primary key of the current tablesuch as ‘id’ of Articles in the above example. - conditions: an array of find() compatible conditions or SQLstrings such as
['Comments.visible' => true]
. It is recommended touse thefinder
option instead. - sort: an array of find() compatible order clauses or SQLstrings such as
['Comments.created' => 'ASC']
- dependent: When dependent is set to
true
, recursive modeldeletion is possible. In this example, Comment records will bedeleted when their associated Article record has been deleted. - cascadeCallbacks: When this and dependent are
true
, cascadeddeletes will load and delete entities so that callbacks are properlytriggered. Whenfalse
,deleteAll()
is used to remove associated dataand no callbacks are triggered. - propertyName: The property name that should be filled with data from theassociated table into the source table results. By default this is theunderscored & plural name of the association so
comments
in our example. - strategy: Defines the query strategy to use. Defaults to ‘select’. Theother valid value is ‘subquery’, which replaces the
IN
list with anequivalent subquery. - saveStrategy: Either ‘append’ or ‘replace’. Defaults to ‘append’. When ‘append’ the currentrecords are appended to any records in the database. When ‘replace’ associatedrecords not in the current set will be removed. If the foreign key is a nullablecolumn or if
dependent
is true records will be orphaned. - finder: The finder method to use when loading associated records. See theUsing Association Finders section for more information.
Once this association has been defined, find operations on the Articles tablecan contain the Comment records if they exist:
- // In a controller or table method.
- $query = $articles->find('all')->contain(['Comments']);
- foreach ($query as $article) {
- echo $article->comments[0]->text;
- }
The above would output SQL similar to:
- SELECT * FROM articles;
- SELECT * FROM comments WHERE article_id IN (1, 2, 3, 4, 5);
When the subquery strategy is used, SQL similar to the following will begenerated:
- SELECT * FROM articles;
- SELECT * FROM comments WHERE article_id IN (SELECT id FROM articles);
You may want to cache the counts for your hasMany associations. This is usefulwhen you often need to show the number of associated records, but don’t want toload all the records just to count them. For example, the comment count on anygiven article is often cached to make generating lists of articles moreefficient. You can use the CounterCacheBehavior to cache counts of associated records.
You should make sure that your database tables do not contain columns that matchassociation property names. If for example you have counter fields that conflictwith association properties, you must either rename the association property, orthe column name.
BelongsToMany Associations
An example of a BelongsToMany association is “Article BelongsToMany Tags”, wherethe tags from one article are shared with other articles. BelongsToMany isoften referred to as “has and belongs to many”, and is a classic “many to many”association.
The main difference between hasMany and BelongsToMany is that the link betweenthe models in a BelongsToMany association is not exclusive. For example, we arejoining our Articles table with a Tags table. Using ‘funny’ as a Tag for myArticle, doesn’t “use up” the tag. I can also use it on the next articleI write.
Three database tables are required for a BelongsToMany association. In theexample above we would need tables for articles
, tags
andarticles_tags
. The articles_tags
table contains the data that linkstags and articles together. The joining table is named after the two tablesinvolved, separated with an underscore by convention. In its simplest form, thistable consists of article_id
and tag_id
.
belongsToMany requires a separate join table that includes both _model_names.
Relationship | Join Table Fields |
---|---|
Articles belongsToMany Tags | articles_tags.id, articles_tags.tag_id, articles_tags.article_id |
Patients belongsToMany Doctors | doctors_patients.id, doctors_patients.doctor_id,doctors_patients.patient_id. |
We can define the belongsToMany association in both our models as follows:
- // In src/Model/Table/ArticlesTable.php
- class ArticlesTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->belongsToMany('Tags');
- }
- }
- // In src/Model/Table/TagsTable.php
- class TagsTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->belongsToMany('Articles');
- }
- }
We can also define a more specific relationship using configuration:
- // In src/Model/Table/TagsTable.php
- class TagsTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->belongsToMany('Articles', [
- 'joinTable' => 'articles_tags',
- ]);
- }
- }
Possible keys for belongsToMany association arrays include:
- className: The class name of the other table. This is the same name usedwhen getting an instance of the table. In the ‘Articles belongsToMany Tags’example, it should be ‘Tags’. The default value is the name of the association.
- joinTable: The name of the join table used in thisassociation (if the current table doesn’t adhere to the namingconvention for belongsToMany join tables). By default this tablename will be used to load the Table instance for the join table.
- foreignKey: The name of the foreign key that references the current modelfound on the join table, or list in case of composite foreign keys.This is especially handy if you need to define multiplebelongsToMany relationships. The default value for this key is theunderscored, singular name of the current model, suffixed with ‘_id’.
- bindingKey: The name of the column in the current table, that will be usedfor matching the
foreignKey
. Defaults to the primary key. - targetForeignKey: The name of the foreign key that references the targetmodel found on the join model, or list in case of composite foreign keys.The default value for this key is the underscored, singular name ofthe target model, suffixed with ‘_id’.
- conditions: An array of
find()
compatible conditions. If you haveconditions on an associated table, you should use a ‘through’ model, anddefine the necessary belongsTo associations on it. It is recommended touse thefinder
option instead. - sort: an array of find() compatible order clauses.
- dependent: When the dependent key is set to
false
, and an entity isdeleted, the data of the join table will not be deleted. - through: Allows you to provide either the alias of the Table instance youwant used on the join table, or the instance itself. This makes customizingthe join table keys possible, and allows you to customize the behavior of thepivot table.
- cascadeCallbacks: When this is
true
, cascaded deletes will load anddelete entities so that callbacks are properly triggered on join tablerecords. Whenfalse
,deleteAll()
is used to remove associated data andno callbacks are triggered. This defaults tofalse
to help reduceoverhead. - propertyName: The property name that should be filled with data from theassociated table into the source table results. By default this is theunderscored & plural name of the association, so
tags
in our example. - strategy: Defines the query strategy to use. Defaults to ‘select’. Theother valid value is ‘subquery’, which replaces the
IN
list with anequivalent subquery. - saveStrategy: Either ‘append’ or ‘replace’. Defaults to ‘replace’.Indicates the mode to be used for saving associated entities. The former willonly create new links between both side of the relation and the latter willdo a wipe and replace to create the links between the passed entities whensaving.
- finder: The finder method to use when loading associated records. See theUsing Association Finders section for more information.
Once this association has been defined, find operations on the Articles table cancontain the Tag records if they exist:
- // In a controller or table method.
- $query = $articles->find('all')->contain(['Tags']);
- foreach ($query as $article) {
- echo $article->tags[0]->text;
- }
The above would output SQL similar to:
- SELECT * FROM articles;
- SELECT * FROM tags
- INNER JOIN articles_tags ON (
- tags.id = article_tags.tag_id
- AND article_id IN (1, 2, 3, 4, 5)
- );
When the subquery strategy is used, SQL similar to the following will begenerated:
- SELECT * FROM articles;
- SELECT * FROM tags
- INNER JOIN articles_tags ON (
- tags.id = article_tags.tag_id
- AND article_id IN (SELECT id FROM articles)
- );
Using the ‘through’ Option
If you plan on adding extra information to the join/pivot table, or if you needto use join columns outside of the conventions, you will need to define thethrough
option. The through
option provides you full control over howthe belongsToMany association will be created.
It is sometimes desirable to store additional data with a many to manyassociation. Consider the following:
- Student BelongsToMany Course
- Course BelongsToMany Student
A Student can take many Courses and a Course can be taken by many Students. Thisis a simple many to many association. The following table would suffice:
- id | student_id | course_id
Now what if we want to store the number of days that were attended by thestudent on the course and their final grade? The table we’d want would be:
- id | student_id | course_id | days_attended | grade
The way to implement our requirement is to use a join model, otherwise knownas a hasMany through association. That is, the association is a modelitself. So, we can create a new model CoursesMemberships. Take a look at thefollowing models:
- class StudentsTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->belongsToMany('Courses', [
- 'through' => 'CoursesMemberships',
- ]);
- }
- }
- class CoursesTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->belongsToMany('Students', [
- 'through' => 'CoursesMemberships',
- ]);
- }
- }
- class CoursesMembershipsTable extends Table
- {
- public function initialize(array $config): void
- {
- $this->belongsTo('Students');
- $this->belongsTo('Courses');
- }
- }
The CoursesMemberships join table uniquely identifies a given Student’sparticipation on a Course in addition to extra meta-information.
Using Association Finders
By default associations will load records based on the foreign key columns. Ifyou want to define addition conditions for associations you can usea finder
. When an association is loaded the ORM will use your customfinder to load, update, or delete associated records.Using finders lets you encapsulate your queries and make them more reusable.There are some limitations when using finders to load data in associations thatare loaded using joins (belongsTo/hasOne). Only the following aspects of thequery will be applied to the root query:
- Where conditions.
- Additional joins.
- Contained associations.
Other aspects of the query, such as selected columns, order, group by, havingand other sub-statements, will not be applied to the root query. Associationsthat are not loaded through joins (hasMany/belongsToMany), do not have theabove restrictions and can also use result formatters or map/reduce functions.
Loading Associations
Once you’ve defined your associations you can eager load associations when fetching results.