These notes relate to creating a Q&A system.
laravel new <projectName>
cd <projectName>
- Set-up database
- Update
.env
with database name php artisan make:auth
php artisan make:model <ModelName> -m
Example schema:
$table->string('title');
$table->string('slug')->unique();
$table->text('body');
$table->unsignedInteger('views')->default(0);
$table->integer('votes')->default(0);
$table->unsignedInteger('best_answer_id')->nullable();
$table->timestamp('created_at')->default(DB::raw('CURRENT_TIMESTAMP'));
$table->timestamp('updated_at')->default(DB::raw('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'));
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
This sets a foreign key for 'user_id' to the 'id' in the 'users' table. It also auto-removes all rows related to the user if their record is deleted from the users table.
php artisan migrate
In Question.php
:
protected $fillable = ['title', 'body'];
/**
* Questions belong to users:
*/
public function user() {
return $this->belongsTo(User::class);
}
In User.php
:
/**
* Users may have many questions:
*/
public function questions()
{
return $this->hasMany(Question::class);
}
In web.php
:
Route::resource('questions', 'QuestionsController');
NOTE: You could also do the following to be more specific:
Route::get('/dummy', 'DummyController@index');
Route::post('/dummy', 'DummyController@store');
Route::delete('/dummy', 'DummyController@destroy');
:
:
...then
php artisan make:controller QuestionsController --resource --model=Question
The --resource
flag adds the following to the controller:
Flag | Description |
---|---|
index() | Display a listing of the resource. |
create() | Show the form for creating a new resource. |
store() | Store a newly created resource in storage. |
show() | Display the specified resource. |
edit() | Show the form for editing the specified resource. |
update() | Update the specified resource in storage. |
destroy() | Remove the specified resource from storage. |
The --model=Question
flag indicates which model to use.
In QuestionsController.php
Get all questions but show only the latest 5 initially:
$questions = Question::latest()->paginate(5);
In QuestionsController.php
return view('questions.index', compact('questions'));
// Limit string length:
str_limit( $original, 100 );
// Show pagination links:
{{ $questions->links() }}
php artisan vendor:publish --tag=laravel-pagination
In index.blade.php
we can specify $question->url
, $question->user->url
and $question->created_dateĂź
:
<div class="media-body">
<h3 class="mt-0"><a href="{{ $question->url }}">{{ $question->title }}</a></h3>
<p class="lead">
Asked by <a href="{{ $question->user->url }}">{{ $question->user->name }}</a>
<small class="text-muted">{{ $question->created_date }}</small>
</p>
{{ str_limit($question->body, 100) }}
</div>
We therefore need to create the attribute accssors in the Question.php
model:
/**
* Attribute accessor for $question->url required in the view:
*/
public function getUrlAttribute()
{
return route('questions.show', $this->id);
}
/**
* Accessor for $question->created_date:
*/
public function getCreatedDateAttribute()
{
return $this->created_at->diffForHumans();
}
...and in User.php
:
/**
* Attribute accessor for $question->user->url required in the view:
*/
public function getUrlAttribute()
{
// return route('questions.show', $this->id);
return '#';
}
If we have:
<div class="status {{ $question->status }}">
then we can create an accessor for the class setting:
/**
* Accessor for $question->status:
*
* Returns a class name relevant to the answered status of the question.
*/
public function getStatusAttribute()
{
if ($this->answers > 0) {
if($this->best_answer_id) {
return 'answered-accepted';
}
return 'answered';
}
return 'unanswered';
}
>>> $q = App\Question::first();
>>> $q->created_at;
>>> $q->created_at->diffForHumans();
>>> $q->created_at->format('Y-m-d H:i');
composer require barryvdh/laravel-debugbar --dev
Lazy loading is where multiple queries are used to extract database data:
$questions = Question::latest()->paginate(5);
The above results in 5 calls to get user data! This can be overcome by adding a with()
clause that makes use of the model relationships we created earlier:
$questions = Question::with('user')->latest()->paginate(5);
This line is found in QuestionsController.php
<a href="{{ route('questions.create') }}" class="btn btn-outline-secondary">Ask Question</a>
<form action="{{ route('questions.store') }}" method="post" class="form">
@csrf
<input type="text" class="form-control {{ $errors->has('title') ? 'is-invalid' : '' }}" name="title" id="title" aria-describedby="helptitle" placeholder="Your question here">
@if ( $errors->has('title') )
<div class="invalid-feedback">
<strong>{{ $errors->first('title') }}</strong>
</div>
@else
<small id="helptitle" class="form-text text-muted">Keep it short!</small>
@endif
We'll use a seperate file for the validation of form submissions (requests), so:
php artisan make:request AskQuestionRequest
Now open app\Http\Requests\AskQuestionRequest.php
to add validations/rules.
public function rules()
{
return [
'title' => 'required|min=5|max=255',
'body' =>'required|min=5'
];
}
In QuestionsController.php
modify the store()
function:
public function store(AskQuestionRequest $request)
{
// Get current user via questions relationship:
$request->user()->questions()->create( $request->only('title', 'body') );
// ^ adds user_id
return redirect()->route('questions.index')->with('success', "Your question has been submitted.");
}
...and import the AskQuestionRequest
file:
use App\Http\Requests\AskQuestionRequest;
Add 'edit' button to right of record's title:
<div class="d-flex align-items-center">
<h3 class="mt-0"><a href="{{ $question->url }}">{{ $question->title }}</a></h3>
<div class="ml-auto">
<a href="{{ route('questions.edit', $question->id) }}" class="btn btn-sm btn-outline-info mr-1">Edit</a>
</div>
</div>
Note the {{ route('questions.edit', $question->id) }}
in the href.
{{ old('title', $question->title) }}
In QuestionsController.php
modify the update()
function:
public function update(AskQuestionRequest $request, Question $question)
{
$question->update( $request->only(['title', 'body']) );
return redirect(route('questions.index'))->with('success', 'Your question has been updated.');
}
Note the update(AskQuestionRequest...
parameter.
In QuestionsController.php
:
public function destroy(Question $question)
{
$question->delete();
return redirect(route('questions.index'))->with('success', "That question has now been deeted.");
}
{!! nl2br(e($question->body)) !!}
In web.php
:
// Let a resource handle all routes except 'show':
Route::resource('questions', 'QuestionsController')->except('show');
// Show question details using the slug - instead of the (default) question id:
Route::get('/questiosns/{slug}', 'QuestionsController@show')->name('questions.show');
In RouteServiceProvider.php
:
public function boot()
{
Route::bind('slug', function($slug) {
// $question = Question::where('slug', $slug)->first();
// return $question ? $question : abort(404);
// ...or:
return Question::where('slug', $slug)->first() ?? abort(404);
});
parent::boot();
}
First install "purifier" using composer require mews/purifier
then:
{!! clean(nl2br($question->body)) !!}
This will allow whitelisted HTML such as <style> and <em>, but not <script>
In AuthServiceProvider.php
's boot()
method:
// Is user authz to edit this question?:
\Gate::define('update-question', function($user, $question) {
// Match current user with tha of the question:
return $user->id == $question->user_id;
});
// Is user authz to delete this quetsion?:
\Gate::define('delete-question', function($user, $question) {
// Match current user with tha of the question:
return $user->id == $question->user_id;
});
In QuestionsController.php
's edit()
method:
if (\Gate::denies('update-question', $question)) {
abort(403, "You don't have access to that question!");
}
return view('questions.edit', compact('question'));
To use the Gates add the following to your template:
@if (Auth::user()->can('delete-question', $question))
NOTE This says: If the user is logged-in, can they delete this question.
First we need to set-up a policy for the Question model:
php artisan make:policy QuestionPolicy --model=Question
Then in app\Policies\QuestionPolicy.php
we can add our auth checks in the relevant (update
& delete
) methods:
public function update(User $user, Question $question)
{
// If the current user is the creator of the question, allow update:
return $user->id === $question->user_id;
}
public function delete(User $user, Question $question)
{
// If the current user is the creator of the question AND the question
// has NO answer then deletion is allowed:
return $user->id === $question->user_id && $question->answers < 1;
}
Now map the Question model to Question policy in AuthServiceProvider.php
:
use App\Question;
use App\Policies\QuestionPolicy;
:
:
protected $policies = [
Question::class => QuestionPolicy::class,
];
In QuestionsController.php
method(s) add:
$this->authorize('<methodName>', $question);
where <methodName> is 'view', 'create', 'update', 'delete', 'restore' or 'forceDelete'.
@if (Auth::user()->can('<methodName>', $question))
// Show something:
@endif
...or:
@can('<methodName>', $question)
// Show something:
@endcan
In QuestionsController.php
add a constructor:
public function __construct() {
$this->middleware('auth', ['except' => ['index', 'show']]);
}
php artisan make:model Answer -m
Now add fields to te model:
Schema::create('answers', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('question_id');
$table->unsignedInteger('user_id');
$table->text('body');
$table->integer('votes_count')->default(0);
$table->timestamp('created_at')->default(DB::raw('CURRENT_TIMESTAMP'));
$table->timestamp('updated_at')->default(DB::raw('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'));
});
...then php artisan migrate
In Question.php
and User.php
add:
/**
* Define relationship - Questions may have many answers:
*/
public function answers()
{
return $this->hasMany(Answer::class);
}
In Answer.php
add:
/**
* Answers belong to questions:
*/
public function question()
{
return $this->belongsTo(Question::class);
}
/**
* Answers belong to users:
*/
public function user()
{
return $this->belongsTo(User::class);
}
php artisan make:migration rename_answers_in_questions_table --table=questions
...then:
composer require doctrine/dbal
...then:
php artisan migrate
??? TODO: Did I miss something here?
php artisan make:factory AnswerFactory
** 6:17 Generating fake answers part 1 of 2 **