One of the most common questions we get from new UserFrosting developers is "how do I add new user fields?"
Since every aspect of UF is extendable, there are a number of ways to go about this. This tutorial just outlines one approach - you should consider the specific requirements of your application and users before deciding if this would be the best approach for you.
Our general constraints are:
users
table directly. This will make it easier to integrate any future updates to UF that affect the users
table. It will also help prevent collisions with any community Sprinkles that modify the users
table. Instead, we will create a separate table, that has a one-to-one relationship with the users
model.If you haven't already, set up your site Sprinkle, as per the instructions in "Your First UserFrosting Site". For the purposes of this tutorial, we will call our Sprinkle extend-user
.
Follow the directions in Chapter 6 for creating a new migration in your Sprinkle. For our example, let's assume we want to add the fields city
and country
:
<?php
use Illuminate\Database\Capsule\Manager as Capsule;
use Illuminate\Database\Schema\Blueprint;
/**
* Member table
*/
if (!$schema->hasTable('members')) {
$schema->create('members', function (Blueprint $table) {
$table->increments('id');
$table->integer('user_id')->unsigned()->unique();
$table->string('city', 255)->nullable();
$table->string('country', 255)->nullable();
$table->timestamps();
$table->engine = 'InnoDB';
$table->collation = 'utf8_unicode_ci';
$table->charset = 'utf8';
$table->foreign('user_id')->references('id')->on('users');
$table->index('user_id');
});
echo "Created table 'members'..." . PHP_EOL;
} else {
echo "Table 'members' already exists. Skipping..." . PHP_EOL;
}
First thing's first, we'll create a data model that corresponds to our new members
table:
<?php
namespace UserFrosting\Sprinkle\ExtendUser\Model;
use UserFrosting\Sprinkle\Core\Model\UFModel;
class Member extends UFModel {
public $timestamps = true;
/**
* @var string The name of the table for the current model.
*/
protected $table = "members";
protected $fillable = [
"user_id",
"city",
"country"
];
/**
* Directly joins the related user, so we can do things like sort, search, paginate, etc.
*/
public function scopeJoinUser($query)
{
/** @var UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = static::$ci->classMapper;
$membersTable = $classMapper->createInstance('member')->getTable();
$usersTable = $classMapper->createInstance('user')->getTable();
$query = $query->select("$membersTable.*");
$query = $query->leftJoin($usersTable, "$membersTable.user_id", '=', "$usersTable.id");
return $query;
}
/**
* Get the user associated with this member.
*/
public function user()
{
/** @var UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = static::$ci->classMapper;
return $this->belongsTo($classMapper->getClassMapping('user'), 'user_id');
}
}
This should be placed in the src/Model/
directory in your own Sprinkle. Notice that we set three properties: $timestamps
, which enables automatic created_at
and updated_at
timestamps for our model, $table
, which should contain the name of your table, and $fillable
, which should be an array of column names that you want to allow to be mass assignable when creating new instances of the model.
We also define two methods. scopeJoinUser
allows us to automatically join the columns in the users
table when we use Laravel's query builder to retrieve members
. For example:
$members = Member::where('city', 'London')->joinUser()->get();
The second method, user()
, defines a one-to-one relationship between Member
and User
. This is similar to what scopeJoinUser()
does, except that it actually creates a completely separate User
object that you can access as a property of an Member
:
$member = Member::where('city', 'London')->first();
// Get the associated user object
$user = $member->user;
Ok, so now we have our Member
model, which stores the additional fields for each user and is related to the User
model via its user_id
column. But, how do we represent this relationship in our Eloquent models? After all, the default User
model that ships with UserFrosting has no idea that Member
even exists.
To bring the two entities together we'll create a third model, MemberUser
, which extends the base User
model to make it aware of the Member
. This virtual model will enable us to interact with columns in both tables as if they were part of a single record.
<?php
namespace UserFrosting\Sprinkle\ExtendUser\Model;
use Illuminate\Database\Capsule\Manager as Capsule;
use UserFrosting\Sprinkle\Account\Model\User;
use UserFrosting\Sprinkle\ExtendUser\Model\Member;
trait LinkMember {
/**
* The "booting" method of the model.
*
* @return void
*/
protected static function bootLinkMember()
{
/**
* Create a new Member if necessary, and save the associated member every time.
*/
static::saved(function ($memberUser) {
$memberUser->createRelatedMemberIfNotExists();
// When creating a new MemberUser, it might not have had a `user_id` when the `member`
// relationship was created. So, we should set it on the Member if it hasn't been set yet.
if (!$memberUser->member->user_id) {
$memberUser->member->user_id = $memberUser->id;
}
$memberUser->member->save();
});
}
}
class MemberUser extends User {
use LinkMember;
protected $fillable = [
"user_name",
"first_name",
"last_name",
"email",
"locale",
"theme",
"group_id",
"flag_verified",
"flag_enabled",
"last_activity_id",
"password",
"deleted_at",
"city",
"country"
];
/**
* Required to be able to access the `member` relationship in Twig without needing to do eager loading.
* @see http://stackoverflow.com/questions/29514081/cannot-access-eloquent-attributes-on-twig/35908957#35908957
*/
public function __isset($name)
{
if (in_array($name, [
'member'
])) {
return true;
} else {
return parent::__isset($name);
}
}
/**
* Custom accessor for Member property
*/
public function getCityAttribute($value)
{
return (count($this->member) ? $this->member->city : '');
}
/**
* Custom accessor for Member property
*/
public function getCountryAttribute($value)
{
return (count($this->member) ? $this->member->country : '');
}
/**
* Get the member associated with this user.
*/
public function member()
{
/** @var UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = static::$ci->classMapper;
return $this->hasOne($classMapper->getClassMapping('member'), 'user_id');
}
/**
* Custom mutator for Member property
*/
public function setCityAttribute($value)
{
$this->createRelatedMemberIfNotExists();
$this->member->city = $value;
}
/**
* Custom mutator for Member property
*/
public function setCountryAttribute($value)
{
$this->createRelatedMemberIfNotExists();
$this->member->country = $value;
}
/**
* If this instance doesn't already have a related Member (either in the db on in the current object), then create one
*/
protected function createRelatedMemberIfNotExists()
{
/** @var UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = static::$ci->classMapper;
if (!count($this->member)) {
$member = $classMapper->createInstance('member', [
'user_id' => $this->id
]);
$this->setRelation('member', $member);
}
}
}
There's a lot going on here, so just a quick tour:
LinkMember
is a trait used to attach handlers to events for our model. In this case, we use the saved
event to tell Laravel to save the related Member
model any time the MemberUser
is saved. It will also call createRelatedMemberIfNotExists
which...well, does exactly what the name says it does.city
and country
to the model's fillable
attributes, so that they can be directly passed in to the MemberUser
model's constructor.__isset
method is overridden to allow Twig to automatically fetch the related member
object (e.g., current_user.member
). See this answer for an explanation of why this is needed.getCityAttribute
and getCountryAttribute
, and two custom mutator methods, setCityAttribute
and setCountryAttribute
. These allow us to interact with the new fields directly through the MemberUser
object (e.g., $memberUser->city
and $memberUser->country
), passing them through to the related Member
model.member()
method defines the relationship with the underlying Member
object.The problem, of course, is that all of the controllers in the Sprinkle that defined the User
model, are still using the User
model (this is simply how inheritance works).
Fortunately, the default Sprinkles never directly reference the User
class. Instead, they use the class mapper. All we need to do, then, is remap the class mapper's user
identifier to our new class, MemberUser
. While we're at it, we can map an identifier for Member
as well. This can be done by extending the classMapper
service in a custom service provider.
Create a class ServicesProvider/ServicesProvider
, and a Sprinkle initializer class, ExtendUser.php
:
<?php
// In /app/sprinkles/site/src/ServicesProvider/ServicesProvider.php
namespace UserFrosting\Sprinkle\ExtendUser\ServicesProvider;
class ServicesProvider
{
/**
* Register extended user fields services.
*
* @param Container $container A DI container implementing ArrayAccess and container-interop.
*/
public function register($container)
{
/**
* Extend the 'classMapper' service to register model classes.
*
* Mappings added: MemberUser
*/
$container->extend('classMapper', function ($classMapper, $c) {
$classMapper->setClassMapping('member', 'UserFrosting\Sprinkle\ExtendUser\Model\Member');
$classMapper->setClassMapping('user', 'UserFrosting\Sprinkle\ExtendUser\Model\MemberUser');
return $classMapper;
});
}
}
ExtendUser.php
:
<?php
// In /app/sprinkles/site/src/ExtendUser.php
namespace UserFrosting\Sprinkle\ExtendUser;
use UserFrosting\Sprinkle\ExtendUser\ServicesProvider\ServicesProvider;
use UserFrosting\Sprinkle\Core\Initialize\Sprinkle;
/**
* Bootstrapper class for the 'extend-user' sprinkle.
*
*/
class ExtendUser extends Sprinkle
{
/**
* Register services.
*/
public function init()
{
$serviceProvider = new ServicesProvider();
$serviceProvider->register($this->ci);
}
}
Now, anywhere that the user
identifier is used with the class mapper, for example:
$user = $classMapper->staticMethod('user', 'where', 'email', '[email protected]')->first();
$member = $user->member;
The class mapper will call the method on the MemberUser
class instead.
You might want your own references to be overrideable by other Sprinkles that might be loaded later on. In this case, you should use the class mapper in your own controllers as well.
user.html.twig
template to display the new fieldsIf we want these new fields to actually show up in our application, we need to add them to our templates. For example, if we add them to components/forms/user.html.twig
, they will be available in user creation, editing, and viewing contexts. So, let's do that by copying the default components/forms/user.html.twig
from the admin
Sprinkle to our own, and then adding city
and country
:
{% if 'address' not in form.fields.hidden %}
<div class="col-sm-6">
<div class="form-group">
<label>City</label>
<div class="input-group js-copy-container">
<span class="input-group-addon"><i class="fa fa-map-pin"></i></span>
<input type="text" class="form-control" name="city" autocomplete="off" value="{{user.city}}" placeholder="City" {% if 'address' in form.fields.disabled %}disabled{% endif %}>
</div>
</div>
</div>
<div class="col-sm-6">
<div class="form-group">
<label>Country</label>
<div class="input-group js-copy-container">
<span class="input-group-addon"><i class="fa fa-map-pin"></i></span>
<input type="text" class="form-control" name="country" autocomplete="off" value="{{user.country}}" placeholder="Country" {% if 'address' in form.fields.disabled %}disabled{% endif %}>
</div>
</div>
</div>
{% endif %}
Notice that we wrap them in a single if
block. By doing this, we are grouping them into a single logical unit, address
, which we can use to decide whether or not to show both fields (for example, via access control). If you need to control the fields individually, then you should wrap them each in their own if
block with more specific names.
I know that we said that we didn't want to modify controllers, but in some cases it is unavoidable. For example, the UserController::pageInfo
method explicitly states the fields that should be displayed in the form. So, we will need to copy and modify it to display the city
and country
fields. Create a new Controller/MemberController.php
class:
<?php
namespace UserFrosting\Sprinkle\ExtendUser\Controller;
use Illuminate\Database\Capsule\Manager as Capsule;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use UserFrosting\Sprinkle\Admin\Controller\UserController;
use UserFrosting\Sprinkle\Core\Facades\Debug;
use UserFrosting\Support\Exception\ForbiddenException;
class MemberController extends UserController
{
}
and copy into it the pageInfo
method from Controller/UserController.php
in the admin
Sprinkle. The full method is too long to show here, but you should find the line that says:
// Determine fields that currentUser is authorized to view
$fieldNames = ['name', 'email', 'locale'];
and add the address
field.
We'll also need to link our endpoints up to this new controller method. To do this, we'll create a new route file, members.php
, in our Sprinkle's routes/
directory:
<?php
/**
* Routes for administrative user management. Overrides routes defined in routes://admin/users.php
*/
$app->group('/admin/users', function () {
$this->get('/u/{user_name}', 'UserFrosting\Sprinkle\ExtendUser\Controller\MemberController:pageInfo');
})->add('authGuard');
Finally, we need to override our request schemas, user/create.json
and user/edit-info.json
, to allow the new city
and country
fields to be submitted during user creation and update requests. Copy both of these from the admin
Sprinkle's schema/user/
directory to your own Sprinkle's schema/user/
directory. Add validation rules for the new fields to both schema:
"city" : {
"validators" : {
"length" : {
"label" : "City",
"min" : 1,
"max" : 255,
"message" : "VALIDATE.LENGTH_RANGE"
}
}
},
"country" : {
"validators" : {
"length" : {
"label" : "Country",
"min" : 1,
"max" : 255,
"message" : "VALIDATE.LENGTH_RANGE"
}
}
}
That's it! A full implementation of this can be found in the extend-user
repository. Check it out!