Symfony2 Jobeet Day 6: More with the Model

* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.

The Doctrine Query Object

From the second day’s requirements: “On the homepage, the user sees the latest active jobs”. But as of now, all jobs are displayed, whether they are active or not:

// ...

class JobController extends Controller
{
    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();

        $entities = $em->getRepository('IbwJobeetBundle:Job')->findAll();

        return $this->render('IbwJobeetBundle:Job:index.html.twig', array(
            'entities' => $entities
        ));

 // ...
}

An active job is one that was posted less than 30 days ago. The $entities = $em->getRepository('IbwJobeetBundle')->findAll() method will make a request to the database to get all the jobs. We are not specifying any condition, which means that all the records are retrieved from the database.

Let’s change it to only select active jobs:

public function indexAction()
{
    $em = $this->getDoctrine()->getManager();

    $query = $em->createQuery(
        'SELECT j FROM IbwJobeetBundle:Job j WHERE j.created_at > :date'
    )->setParameter('date', date('Y-m-d H:i:s', time() - 86400 * 30));
    $entities = $query->getResult();

    return $this->render('IbwJobeetBundle:Job:index.html.twig', array(
        'entities' => $entities
    ));
}

Debugging Doctrine generated SQL

Sometimes, it is of great help to see the SQL generated by Doctrine; for instance, to debug a query that does not work as expected. In the dev environment, thanks to the Symfony Web Debug Toolbar, all the information you need is available within the comfort of your browser (http://jobeet.local/app_dev.php):

Day 6 - web debug toolbar

Object Serialization

Even if the code above works, it is far from perfect as it does not take into account some requirements from Day 2: “A user can come back to re-activate or extend the validity of the job for an extra 30 days..”.

But as the above code only relies on the created_at value, and because this column stores the creation date, we cannot satisfy the above requirement.

If you remember the database schema we have described during Day 3, we also have defined an expires_at column. Currently, if this value is not set in fixture file, it remains always empty. But when a job is created, it can be automatically set to 30 days after the current date.

When you need to do something automatically before a Doctrine object is serialized to the database, you can add a new action to the lifecycle callbacks in the file that maps objects to the database, like we did earlier for the created_at column:

# ...
    # ...
    lifecycleCallbacks:
        prePersist: [ setCreatedAtValue, setExpiresAtValue ]
        preUpdate: [ setUpdatedAtValue ]

Now, we have to rebuild the entities classes so Doctrine will add the new function:

php app/console doctrine:generate:entities IbwJobeetBundle

Open the src/Ibw/JobeetBundle/Entity/Job.php file and edit the new added function:

// ...

class Job
{
    // ... 

    public function setExpiresAtValue()
    {
        if(!$this->getExpiresAt()) {
            $now = $this->getCreatedAt() ? $this->getCreatedAt()->format('U') : time();
            $this->expires_at = new DateTime(date('Y-m-d H:i:s', $now + 86400 * 30));
        }
    }
}

Now, let’s change the action to use the expires_at column instead of the created_at one to select the active jobs:

// ...

    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();

        $query = $em->createQuery(
            'SELECT j FROM IbwJobeetBundle:Job j WHERE j.expires_at > :date'
    )->setParameter('date', date('Y-m-d H:i:s', time()));
        $entities = $query->getResult();

        return $this->render('IbwJobeetBundle:Job:index.html.twig', array(
            'entities' => $entities
        ));
    }

// ...

More with Fixtures

Refreshing the Jobeet homepage in your browser won’t change anything, as the jobs in the database have been posted just a few days ago. Let’s change the fixtures to add a job that is already expired:

// ...

    public function load(ObjectManager $em)
    {
        $job_expired = new Job();
        $job_expired->setCategory($em->merge($this->getReference('category-programming')));
        $job_expired->setType('full-time');
        $job_expired->setCompany('Sensio Labs');
        $job_expired->setLogo('sensio-labs.gif');
        $job_expired->setUrl('http://www.sensiolabs.com/');
        $job_expired->setPosition('Web Developer Expired');
        $job_expired->setLocation('Paris, France');
        $job_expired->setDescription('Lorem ipsum dolor sit amet, consectetur adipisicing elit.');
        $job_expired->setHowToApply('Send your resume to lorem.ipsum [at] dolor.sit');
        $job_expired->setIsPublic(true);
        $job_expired->setIsActivated(true);
        $job_expired->setToken('job_expired');
        $job_expired->setEmail('job@example.com');
        $job_expired->setCreatedAt(new DateTime('2005-12-01'));

        // ...

        $em->persist($job_expired);
        // ...
    }

// ...

Reload the fixtures and refresh your browser to ensure that the old job does not show up:

php app/console doctrine:fixtures:load

Refactoring

Although the code we have written works fine, it’s not quite right yet. Can you spot the problem?

The Doctrine query code does not belong to the action (the Controller layer), it belongs to the Model layer. In the MVC model, the Model defines all the business logic, and the Controller only calls the Model to retrieve data from it. As the code returns a collection of jobs, let’s move the code to the model. For that we will need to create a custom repository class for Job entity and to add the query to that class.

Open /src/Ibw/JobeetBundle/Resources/config/doctrine/Job.orm.yml and add the following to it:

IbwJobeetBundleEntityJob:
    type: entity
    repositoryClass: IbwJobeetBundleRepositoryJobRepository
    # ...

Doctrine can generate the repository class for you by running the generate:entities command used earlier:

php app/console doctrine:generate:entities IbwJobeetBundle

Next, add a new method – getActiveJobs() – to the newly generated repository class. This method will query for all of the active Job entities sorted by the expires_at column (and filtered by category, if it receives the $category_id parameter).

namespace IbwJobeetBundleRepository;

use DoctrineORMEntityRepository;

/**
 * JobRepository
 *
 * This class was generated by the Doctrine ORM. Add your own custom
 * repository methods below.
 */
class JobRepository extends EntityRepository
{
    public function getActiveJobs($category_id = null)
    {
        $qb = $this->createQueryBuilder('j')
            ->where('j.expires_at > :date')
            ->setParameter('date', date('Y-m-d H:i:s', time()))
            ->orderBy('j.expires_at', 'DESC');

        if($category_id)
        {
            $qb->andWhere('j.category = :category_id')
                ->setParameter('category_id', $category_id);
        }

        $query = $qb->getQuery();

        return $query->getResult();
    }
}

Now the action code can use this new method to retrieve the active jobs.

// ...

    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();

        $entities = $em->getRepository('IbwJobeetBundle:Job')->getActiveJobs();

        return $this->render('IbwJobeetBundle:Job:index.html.twig', array(
            'entities' => $entities
        ));
    }

// ...

This refactoring has several benefits over the previous code:

  • The logic to get the active jobs is now in the Model, where it belongs
  • The code in the controller is thinner and much more readable
  • The getActiveJobs() method is re-usable (for instance in another action)
  • The model code is now unit testable

Categories on the Homepage

According to the second day’s requirements we need to have jobs sorted by categories. Until now, we have not taken the job category into account. From the requirements, the homepage must display jobs by category. First, we need to get all categories with at least one active job.

Create a repository class for the Category entity like we did for Job:

IbwJobeetBundleEntityCategory:
    type: entity
    repositoryClass: IbwJobeetBundleRepositoryCategoryRepository
    #...

Generate the repository class:

php app/console doctrine:generate:entities IbwJobeetBundle

Open the CategoryRepository class and add a getWithJobs() method:

namespace IbwJobeetBundleRepository;

use DoctrineORMEntityRepository;

/**
 * CategoryRepository
 *
 * This class was generated by the Doctrine ORM. Add your own custom
 * repository methods below.
 */
class CategoryRepository extends EntityRepository
{
    public function getWithJobs()
    {
        $query = $this->getEntityManager()->createQuery(
            'SELECT c FROM IbwJobeetBundle:Category c LEFT JOIN c.jobs j WHERE j.expires_at > :date'
        )->setParameter('date', date('Y-m-d H:i:s', time()));

        return $query->getResult();
    }   
}

Change the index action accordingly:

// ...

    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();

        $categories = $em->getRepository('IbwJobeetBundle:Category')->getWithJobs();

        foreach($categories as $category) {
            $category->setActiveJobs($em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId()));
        }

        return $this->render('IbwJobeetBundle:Job:index.html.twig', array(
            'categories' => $categories
        ));
    }

// ...

For this to work, we have to add a new property to our Category class, the active_jobs:

class Category
{
    // ...

    private $active_jobs;

    // ...

    public function setActiveJobs($jobs)
    {
        $this->active_jobs = $jobs;
    }

    public function getActiveJobs()
    {
        return $this->active_jobs;
    }
}

In the template, we need to iterate through all categories and display the active jobs:

<!-- ... -->
{% block content %}
    <div id="jobs">
        {% for category in categories %}
            <div>
                <div class="category">
                    <div class="feed">
                        <a href="">Feed</a>
                    </div>
                    <h1>{{ category.name }}</h1>
                </div>
                <table class="jobs">
                    {% for entity in category.activejobs %}
                        <tr class="{{ cycle(['even', 'odd'], loop.index) }}">
                            <td class="location">{{ entity.location }}</td>
                            <td class="position">
                                <a href="{{ path('ibw_job_show', { 'id': entity.id, 'company': entity.companyslug, 'location': entity.locationslug, 'position': entity.positionslug }) }}">
                                    {{ entity.position }}
                                </a>
                            </td>
                             <td class="company">{{ entity.company }}</td>
                        </tr>
                    {% endfor %}
                </table>
            </div>
        {% endfor %}
    </div>
{% endblock %}

Limit the results

There is still one requirement to implement for the homepage job list: we have to limit the job list to 10 items. That’s simple enough to add the $max parameter to the JobRepository::getActiveJobs() method:

    public function getActiveJobs($category_id = null, $max = null)
    {
        $qb = $this->createQueryBuilder('j')
            ->where('j.expires_at > :date')
            ->setParameter('date', date('Y-m-d H:i:s', time()))
            ->orderBy('j.expires_at', 'DESC');

        if($max) {
            $qb->setMaxResults($max);
        }

        if($category_id) {
            $qb->andWhere('j.category = :category_id')
                ->setParameter('category_id', $category_id);
        }

        $query = $qb->getQuery();

        return $query->getResult();
    }

Change the call to getActiveJobs() to include the $max parameter:

// ...

    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();

        $categories = $em->getRepository('IbwJobeetBundle:Category')->getWithJobs();

        foreach($categories as $category)
        {
            $category->setActiveJobs($em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId(), 10));
        }

        return $this->render('IbwJobeetBundle:Job:index.html.twig', array(
            'categories' => $categories
        ));
    }

// ...

Custom Configuration

In the JobController, indexAction method, we have hardcoded the number of max jobs returned for a category. It would have been better to make the 10 limit configurable. In Symfony, you can define custom parameters for your application in the app/config/config.yml file, under the parameters key (if the parameters key doesn’t exist, create it):

# ...

parameters:
    max_jobs_on_homepage: 10

This can now be accessed from a controller:

// ...

    public function indexAction()
    {
        $em = $this->getDoctrine()->getManager();

        $categories = $em->getRepository('IbwJobeetBundle:Category')->getWithJobs();

        foreach($categories as $category) {
            $category->setActiveJobs($em->getRepository('IbwJobeetBundle:Job')->getActiveJobs($category->getId(), $this->container->getParameter('max_jobs_on_homepage')));
        }

        return $this->render('IbwJobeetBundle:Job:index.html.twig', array(
            'categories' => $categories
        ));
    }

// ...

Dinamic Fixtures

For now, you won’t see any difference because we have a very small amount of jobs in our database. We need to add a bunch of jobs to the fixture. So, you can copy and paste an existing job ten or twenty times by hand… but there’s a better way. Duplication is bad, even in fixture files:

// ...

public function load(ObjectManager $em)
{
    // ...

    for($i = 100; $i &lt;= 130; $i++)
    {
        $job = new Job();
        $job-&gt;setCategory($em-&gt;merge($this-&gt;getReference('category-programming')));
        $job-&gt;setType('full-time');
        $job-&gt;setCompany('Company '.$i);
        $job-&gt;setPosition('Web Developer');
        $job-&gt;setLocation('Paris, France');
        $job-&gt;setDescription('Lorem ipsum dolor sit amet, consectetur adipisicing elit.');
        $job-&gt;setHowToApply('Send your resume to lorem.ipsum [at] dolor.sit');
        $job-&gt;setIsPublic(true);
        $job-&gt;setIsActivated(true);
        $job-&gt;setToken('job_'.$i);
        $job-&gt;setEmail('job@example.com');

        $em-&gt;persist($job);
    }

    // ... 
    $em-&gt;flush();
}

// ...

You can now reload the fixtures with the doctrine:fixtures:load task and see if only 10 jobs are displayed on the homepage for the Programming category:

Day 6 - limited no of jobs

Secure the Job Page

When a job expires, even if you know the URL, it must not be possible to access it anymore. Try the URL for the expired job (replace the id with the actual id in your database – SELECT id, token FROM job WHERE expires_at < NOW()):

/app_dev.php/job/sensio-labs/paris-france/ID/web-developer-expired

Instead of displaying the job, we need to forward the user to a 404 page. For this we will create a new function in the JobRepository:

// ...

    public function getActiveJob($id)
    {
        $query = $this->createQueryBuilder('j')
            ->where('j.id = :id')
            ->setParameter('id', $id)
            ->andWhere('j.expires_at > :date')
            ->setParameter('date', date('Y-m-d H:i:s', time()))
            ->setMaxResults(1)
            ->getQuery();

        try {
            $job = $query->getSingleResult();
        } catch (DoctrineOrmNoResultException $e) {
            $job = null;
        }

        return $job;
    }

The getSingleResult() method throws a DoctrineORMNoResultException exception if no results are returned and a DoctrineORMNonUniqueResultException if more than one result is returned. If you use this method, you may need to wrap it in a try-catch blockand ensure that only one result is returned.

Now change the showAction() from the JobController to use the new repository method:

// ...

$entity = $em->getRepository('IbwJobeetBundle:Job')->getActiveJob($id);

// ...

Now, if you try to get an expired job, you will be forwarded to a 404 page:

Day 6 - no job found

That’s all for today! We will see you again tomorrow, when we’ll be playing with the category page.

Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.


Symfony2 Jobeet Day 5: The Routing

* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.

URLs

If you click on a job on the Jobeet homepage, the URL looks like this: /job/1/show. If you have already developed PHP websites, you are probably more accustomed to URLs like /job.php?id=1. How does Symfony make it work? How does Symfony determine the action to call based on this URL? Why is the id of the job retrieved with the $id parameter in the action? Here, we will answer all these questions.

You have already seen the following code in the src/Ibw/JobeetBundle/Resources/views/Job/index.html.twig template:

{{ path('ibw_job_show', { 'id': entity.id }) }}

This uses the path template helper function to generate the url for the job which has the id 1. The ibw_job_show is the name of the route used, defined in the configuration as you will see below.

Routing Configuration

In Symfony2, routing configuration is usually done in the app/config/routing.yml. This imports specific bundle routing configuration. In our case, the src/Ibw/JobeetBundle/Resources/config/routing.yml file is imported:

ibw_jobeet:
    resource: "@IbwJobeetBundle/Resources/config/routing.yml"
    prefix:   /

Now, if you look in the JobeetBundle routing.yml you will see that it imports another routing file, the one for the Job controller and defines a route called ibw_jobeet_homepage for the /hello/{name} URL pattern:

IbwJobeetBundle_job:
    resource: "@IbwJobeetBundle/Resources/config/routing/job.yml"
    prefix: /job

ibw_jobeet_homepage:
    pattern:  /hello/{name}
    defaults: { _controller: IbwJobeetBundle:Default:index }
ibw_job:
    pattern:  /
    defaults: { _controller: "IbwJobeetBundle:Job:index" }

ibw_job_show:
    pattern:  /{id}/show
    defaults: { _controller: "IbwJobeetBundle:Job:show" }

ibw_job_new:
    pattern:  /new
    defaults: { _controller: "IbwJobeetBundle:Job:new" }

ibw_job_create:
    pattern:  /create
    defaults: { _controller: "IbwJobeetBundle:Job:create" }
    requirements: { _method: post }

ibw_job_edit:
    pattern:  /{id}/edit
    defaults: { _controller: "IbwJobeetBundle:Job:edit" }

ibw_job_update:
    pattern:  /{id}/update
    defaults: { _controller: "IbwJobeetBundle:Job:update" }
    requirements: { _method: post|put }

ibw_job_delete:
    pattern:  /{id}/delete
    defaults: { _controller: "IbwJobeetBundle:Job:delete" }
    requirements: { _method: post|delete }

Let’s have a closer look to the ibw_job_show route. The pattern defined by the ibw_job_show route acts like /*/show where the wildcard is given the name id. For the URL /1/show, the id variable gets a value of 1, which is available for you to use in your controller. The _controller parameter is a special key that tells Symfony which controller/action should be executed when a URL matches this route, in our case it should execute the showAction from the JobController in the IbwJobeetBundle.

The route parameters (e.g. {id}) are especially important because each is made available as an argument to the controller method.

Routing Configuration in Dev Environment

The dev environment loads the app/config/routing_dev.yml file that contains the routes used by the Web Debug Toolbar (you already deleted the routes for the AcmeDemoBundle from /app/config/routing_dev.php – see Day 1, How to remove the AcmeDemoBundle). This file loads, at the end, the main routing.yml configuration file.

Route Customizations

For now, when you request the / URL in a browser, you will get a 404 Not Found error. That’s because this URL does not match any routes defined. We have a ibw_jobeet_homepage route that matches the /hello/jobeet URL and sends us to the DefaultController, index action. Let’s change it to match the / URL and to call the index action from the JobController. To make the change, modify it to the following:

# ...
ibw_jobeet_homepage:
    pattern:  /
    defaults: { _controller: IbwJobeetBundle:Job:index }

Now, if you clear the cache and go to http://jobeet.local from your browser, you will see the Job homepage. We can now change the link of the Jobeet logo in the layout to use the ibw_jobeet_homepage route:

<!-- ... -->    
    <h1><a href="{{ path('ibw_jobeet_homepage') }}">
        <img alt="Jobeet Job Board" src="{{ asset('bundles/ibwjobeet/images/logo.jpg') }}" />
    </a></h1>
<!-- ... -->

For something a bit more involved, let’s change the job page URL to something more meaningful:

/job/sensio-labs/paris-france/1/web-developer

Without knowing anything about Jobeet, and without looking at the page, you can understand from the URL that Sensio Labs is looking for a Web developer to work in Paris, France.

The following pattern matches such a URL:

/job/{company}/{location}/{id}/{position}

Edit the ibw_job_show route from the job.yml file:

# ...

ibw_job_show:
    pattern:  /{company}/{location}/{id}/{position}
    defaults: { _controller: "IbwJobeetBundle:Job:show" }

Now, we need to pass all the parameters for the changed route for it to work:

<!-- ... -->
<a href="{{ path('ibw_job_show', { 'id': entity.id, 'company': entity.company, 'location': entity.location, 'position': entity.position }) }}">
    {{ entity.position }}
</a>
<!-- ... -->

If you have a look at generated URLs, they are not quite yet as we want them to be:

http://jobeet.local/app_dev.php/job/Sensio Labs/Paris,France/1/Web Developer
We need to “slugify” the column values by replacing all non ASCII characters by a -. Open the Job.php file and add the following methods to the class:
// ...
use IbwJobeetBundleUtilsJobeet as Jobeet;

class Job
{
    // ...

    public function getCompanySlug()
    {
        return Jobeet::slugify($this->getCompany());
    }

    public function getPositionSlug()
    {
        return Jobeet::slugify($this->getPosition());
    }

    public function getLocationSlug()
    {
        return Jobeet::slugify($this->getLocation());
    }
}

You must also add the use statement before the Job class definition.
After that, create the src/Ibw/JobeetBundle/Utils/Jobeet.php file and add the slugify method in it:

namespace IbwJobeetBundleUtils;

class Jobeet
{
    static public function slugify($text)
    {
        // replace all non letters or digits by -
        $text = preg_replace('/W+/', '-', $text);

        // trim and lowercase
        $text = strtolower(trim($text, '-'));

        return $text;
    }
}

We have defined three new “virtual” accessors: getCompanySlug(), getPositionSlug(), and getLocationSlug(). They return their corresponding column value after applying it the slugify() method. Now, you can replace the real column names by these virtual ones in the template:

<!-- ... -->
<a href="{{ path('ibw_job_show', { 'id': entity.id, 'company': entity.companyslug, 'location': entity.locationslug, 'position': entity.positionslug}) }}">
   {{ entity.position }}
</a>
<!-- ... -->

Route Requirements

The routing system has a built-in validation feature. Each pattern variable can be validated by a regular expression defined using the requirements entry of a route definition:

# ...
ibw_job_show:
    pattern:  /{company}/{location}/{id}/{position}
    defaults: { _controller: "IbwJobeetBundle:Job:show" }
    requirements:
        id:  d+

# ...

The above requirements entry forces the id to be a numeric value. If not, the route won’t match.

Route Debugging

While adding and customizing routes, it’s helpful to be able to visualize and get detailed information about your routes. A great way to see every route in your application is via the router:debug console command. Execute the command by running the following from the root of your project:

php app/console router:debug

The command will print a helpful list of all the configured routes in your application. You can also get very specific information on a single route by including the route name after the command:

php app/console router:debug ibw_job_show

Final Thoughts

That’s all for today! To learn more about the Symfony2 routing system read the Routing chapter form the book.

Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.


Symfony2 Jobeet Day 4: The Controller and the View

* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.

Today, we are going to customize the basic job controller we created yesterday. It already has most of the code we need for Jobeet:

  • A page to list all jobs
  • A page to create a new job
  • A page to update an existing job
  • A page to delete a job

Although the code is ready to be used as is, we will refactor the templates to match closer to the Jobeet mockups.

The MVC Arhitecture

For web development, the most common solution for organizing your code nowadays is the MVC design pattern. In short, the MVC design pattern defines a way to organize your code according to its nature. This pattern separates the code into three layers:

  • The Model layer defines the business logic (the database belongs to this layer). You already know that Symfony stores all the classes and files related to the Model in the Entity/ directory of your bundles.
  • The View is what the user interacts with (a template engine is part of this layer). In Symfony 2.3.2, the View layer is mainly made of Twig templates. They are stored in various Resources/views/ directories as we will see later in these lines.
  • The Controller is a piece of code that calls the Model to get some data that it passes to the View for rendering to the client. When we installed Symfony at the beginning of this tutorial, we saw that all requests are managed by front controllers (app.php and app_dev.php). These front controllers delegate the real work to actions.

The Layout

If you have a closer look at the mockups, you will notice that much of each page looks the same. You already know that code duplication is bad, whether we are talking about HTML or PHP code, so we need to find a way to prevent these common view elements from resulting in code duplication.

One way to solve the problem is to define a header and a footer and include them in each template. A better way is to use another design pattern to solve this problem: the decorator design pattern. The decorator design pattern resolves the problem the other way around: the template is decorated after the content is rendered by a global template, called a layout.

Symfony2 does not came with a default layout, so we will create one and use it to decorate our application pages.

Create a new file layout.html.twig in the src/Ibw/JobeetBundle/Resources/views/ directory and put in the following code:

<!DOCTYPE html>
<html>
    <head>
        <title>
            {% block title %}
                Jobeet - Your best job board
            {% endblock %}
        </title>
        <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
        {% block stylesheets %}
            <link rel="stylesheet" href="{{ asset('bundles/ibwjobeet/css/main.css') }}" type="text/css" media="all" />
        {% endblock %}
        {% block javascripts %}
        {% endblock %}
        <link rel="shortcut icon" href="{{ asset('bundles/ibwjobeet/images/favicon.ico') }}" />
    </head>
    <body>
        <div id="container">
            <div id="header">
                <div class="content">
                    <h1><a href="{{ path('ibw_job') }}">
                        <img src="{{ asset('bundles/ibwjobeet/images/logo.jpg') }}" alt="Jobeet Job Board" />
                    </a></h1>

                    <div id="sub_header">
                        <div class="post">
                            <h2>Ask for people</h2>
                            <div>
                                <a href="{{ path('ibw_job') }}">Post a Job</a>
                            </div>
                        </div>

                        <div class="search">
                            <h2>Ask for a job</h2>
                            <form action="" method="get">
                                <input type="text" name="keywords" id="search_keywords" />
                                <input type="submit" value="search" />
                                <div class="help">
                                    Enter some keywords (city, country, position, ...)
                                </div>
                            </form>
                        </div>
                    </div>
                </div>
            </div>

           <div id="content">
               {% for flashMessage in app.session.flashbag.get('notice') %}
                   <div class="flash_notice">
                       {{ flashMessage }}
                   </div>
               {% endfor %}

               {% for flashMessage in app.session.flashbag.get('error') %}
                   <div class="flash_error">
                       {{ flashMessage }}
                   </div>
               {% endfor %}

               <div class="content">
                   {% block content %}
                   {% endblock %}
               </div>
           </div>

           <div id="footer">
               <div class="content">
                   <span class="symfony">
                       <img src="{{ asset('bundles/ibwjobeet/images/jobeet-mini.png') }}" />
                           powered by <a href="http://www.symfony.com/">
                           <img src="{{ asset('bundles/ibwjobeet/images/symfony.gif') }}" alt="symfony framework" />
                       </a>
                   </span>
                   <ul>
                       <li><a href="">About Jobeet</a></li>
                       <li class="feed"><a href="">Full feed</a></li>
                       <li><a href="">Jobeet API</a></li>
                       <li class="last"><a href="">Affiliates</a></li>
                   </ul>
               </div>
           </div>
       </div>
   </body>
</html>

Twig Blocks

In Twig, the default Symfony template engine, you can define blocks as we did above. A twig block can have a default content (look at the title block, for example) that can be replaced or extended in the child template as you will see in a moment.

Now, to make use of the layout we created, we will need to edit all the job templates (index, edit, new and show from src/Ibw/JobeetBundle/Resources/views/Job/) to extend the parent template (the layout) and to overwrite the content block we defined with the body block content from the original template

{% extends 'IbwJobeetBundle::layout.html.twig' %}

{% block content %}
    <!-- original body block code goes here -->
{% endblock %}

The Stylesheets, Images and JavaScripts

As this is not about web design, we have already prepared all the needed assets we will use for Jobeet: download the image files archive and put them into the src/Ibw/JobeetBundle/Resources/public/images/ directory; download the stylesheet files archive and put them into the src/Ibw/JobeetBundle/Resources/public/css/ directory.

Now run

php app/console assets:install web --symlink

to tell Symfony to make them available to the public.

If you look in the css folder, you will notice that we have four css files: admin.css, job.css,jobs.css and main.css. The main.css is needed in all Jobeet pages, so we included it in the layout in the stylesheet twig block. The rest are more specialized css files and we need them only in specific pages.

To add a new css file in a template, we will overwrite the stylesheet block, but call the parent before adding the new css file (so we would have the main.css and the additional css files we need).

{% extends 'IbwJobeetBundle::layout.html.twig' %}

{% block stylesheets %}
    {{ parent() }}
    <link rel="stylesheet" href="{{ asset('bundles/ibwjobeet/css/jobs.css') }}" type="text/css" media="all" />
{% endblock %}

<!-- rest of the code -->
{% extends 'IbwJobeetBundle::layout.html.twig' %}

{% block stylesheets %}
    {{ parent() }}
    <link rel="stylesheet" href="{{ asset('bundles/ibwjobeet/css/job.css') }}" type="text/css" media="all" />
{% endblock %}

<!-- rest of the code -->

The Job Homepage Action

Each action is represented by a method of a class. For the job homepage, the class is JobController and the method is indexAction(). It retrieves all the jobs from the database.

// ...

public function indexAction()
{
    $em = $this->getDoctrine()->getManager();

    $entities = $em->getRepository('IbwJobeetBundle:Job')->findAll();

    return $this->render('IbwJobeetBundle:Job:index.html.twig', array(
        'entities' => $entities
    ));
}

// ...

Let’s have a closer look at the code: the indexAction() method gets the Doctrine entity manager object, which is responsible for handling the process of persisting and fetching objects to and from database, and then the repository, that will create a query to retrieve all the jobs. It returns a Doctrine ArrayCollection of Job objects that are passed to the template (the View).

The Job Homepage Template

The index.html.twig template generates an HTML table for all the jobs. Here is the current template code:

{% extends 'IbwJobeetBundle::layout.html.twig' %}

{% block stylesheets %}
    {{ parent() }}
    <link rel="stylesheet" href="{{ asset('bundles/ibwjobeet/css/jobs.css') }}" type="text/css" media="all" />
{% endblock %}

{% block content %}
    <h1>Job list</h1>

    <table class="records_list">
        <thead>
            <tr>
                <th>Id</th>
                <th>Type</th>
                <th>Company</th>
                <th>Logo</th>
                <th>Url</th>
                <th>Position</th>
                <th>Location</th>
                <th>Description</th>
                <th>How_to_apply</th>
                <th>Token</th>
                <th>Is_public</th>
                <th>Is_activated</th>
                <th>Email</th>
                <th>Expires_at</th>
                <th>Created_at</th>
                <th>Updated_at</th>
                <th>Actions</th>
            </tr>
        </thead>
        <tbody>
        {% for entity in entities %}
            <tr>
                <td><a href="{{ path('ibw_job_show', { 'id': entity.id }) }}">{{ entity.id }}</a></td>
                <td>{{ entity.type }}</td>
                <td>{{ entity.company }}</td>
                <td>{{ entity.logo }}</td>
                <td>{{ entity.url }}</td>
                <td>{{ entity.position }}</td>
                <td>{{ entity.location }}</td>
                <td>{{ entity.description }}</td>
                <td>{{ entity.howtoapply }}</td>
                <td>{{ entity.token }}</td>
                <td>{{ entity.ispublic }}</td>
                <td>{{ entity.isactivated }}</td>
                <td>{{ entity.email }}</td>
                <td>{% if entity.expiresat %}{{ entity.expiresat|date('Y-m-d H:i:s') }}{% endif%}</td>
                <td>{% if entity.createdat %}{{ entity.createdat|date('Y-m-d H:i:s') }}{% endif%}</td>
                <td>{% if entity.updatedat %}{{ entity.updatedat|date('Y-m-d H:i:s') }}{% endif%}</td>
                <td>
                    <ul>
                        <li>
                            <a href="{{ path('ibw_job_show', { 'id': entity.id }) }}">show</a>
                        </li>
                        <li>
                            <a href="{{ path('ibw_job_edit', { 'id': entity.id }) }}">edit </a>
                        </li>
                    </ul>
                </td>
            </tr>
        {% endfor %}
        </tbody>
    </table>

    <ul>
        <li>
            <a href="{{ path('ibw_job_new') }}">
                Create a new entry
            </a>
        </li>
    </ul>
{% endblock %}

Let’s clean this up a bit to only display a sub-set of the available columns. Replace the twig block content with the one below:

{% block content %}
    <div id="jobs">
        <table class="jobs">
            {% for entity in entities %}
                <tr class="{{ cycle(['even', 'odd'], loop.index) }}">
                    <td class="location">{{ entity.location }}</td>
                    <td class="position">
                        <a href="{{ path('ibw_job_show', { 'id': entity.id }) }}">
                            {{ entity.position }}
                        </a>
                    </td>
                    <td class="company">{{ entity.company }}</td>
                </tr>
            {% endfor %}
        </table>
    </div>
{% endblock %}

Day 4 - 2 jobs

The Job Page Template

Now let’s customize the template of the job page. Open the show.html.twig file and replace its content with the following code:

{% extends 'IbwJobeetBundle::layout.html.twig' %}

{% block title %}
    {{ entity.company }} is looking for a {{ entity.position }}
{% endblock %}

{% block stylesheets %}
    {{ parent() }}
    <link rel="stylesheet" href="{{ asset('bundles/ibwjobeet/css/job.css') }}" type="text/css" media="all" />
{% endblock %}

{% block content %}
    <div id="job">
        <h1>{{ entity.company }}</h1>
        <h2>{{ entity.location }}</h2>
        <h3>
            {{ entity.position }}
            <small> - {{ entity.type }}</small>
        </h3>

        {% if entity.logo %}
            <div class="logo">
                <a href="{{ entity.url }}">
                    <img src="/uploads/jobs/{{ entity.logo }}"
                        alt="{{ entity.company }} logo" />
                </a>
            </div>
        {% endif %}

        <div class="description">
            {{ entity.description|nl2br }}
        </div>

        <h4>How to apply?</h4>

        <p class="how_to_apply">{{ entity.howtoapply }}</p>

        <div class="meta">
            <small>posted on {{ entity.createdat|date('m/d/Y') }}</small>
        </div>

        <div style="padding: 20px 0">
            <a href="{{ path('ibw_job_edit', { 'id': entity.id }) }}">
                Edit
            </a>
        </div>
    </div>
{% endblock %}

Day 4 - individual job

The Job Page Action

The job page is generated by the show action, defined in the showAction() method of the JobController:

public function showAction($id)
{
    $em = $this->getDoctrine()->getManager();

    $entity = $em->getRepository('IbwJobeetBundle:Job')->find($id);

    if (!$entity) {
        throw $this->createNotFoundException('Unable to find Job entity.');
    }

    $deleteForm = $this->createDeleteForm($id);

    return $this->render('IbwJobeetBundle:Job:show.html.twig', array(
        'entity' => $entity,
        'delete_form' => $deleteForm->createView(),
    ));
}

As in the index action, the IbwJobeetBundle repository class is used to retrieve a job, this time using the find() method. The parameter of this method is the unique identifier of a job, its primary key. The next section will explain why the $id parameter of the actionShow() function contains the job primary key.

If the job does not exist in the database, we want to forward the user to a 404 page, which is exactly what the throw $this->createNotFoundException() does.

As for exceptions, the page displayed to the user is different in the prod environment and in the dev ennvironment.

Day 4 - error1

Day 4 - error2

That’s all for today! Tomorrow we will get you familiar with the routing features.

Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.


Symfony2 Jobeet Day 3: The Data Model

* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.

If you’re itching to open your text editor and lay down some PHP, you will be happy to know that today will get us into some development. We will define the Jobeet data model, use an ORM to interact with the database and build the first module of the application. But as Symfony does a lot of work for us, we will have a fully functional web module without writing too much PHP code.

The Relational Model

The user stories from the previous day describe the main objects of our project: jobs, affiliates, and categories. Here is the corresponding entity relationship diagram:

Day3 - entity_diagram

 

In addition to the columns described in the stories, we have also added created_at and updated_at columns. We will configure Symfony to set their value automatically when an object is saved or updated.

The Database

To store the jobs, affiliates and categories in the database, Symfony 2.3.2 uses Doctrine ORM. To define the database connection parameters, you have to edit the app/config/parameters.yml file (for this tutorial we will use MySQL):

parameters:
    database_driver: pdo_mysql
    database_host: localhost
    database_port: null
    database_name: jobeet
    database_user: root
    database_password: password
    # ...

Now that Doctrine knows about your database, you can have it create the database for you by typing the following command in your terminal:

php app/console doctrine:database:create

The Schema

To tell Doctrine about our objects, we will create “metadata” files that will describe how our objects will be stored in the database. Now go to your code editor and create a directory named doctrine, inside src/Ibw/JobeetBundle/Resources/config directory. Doctrine will contain three files: Category.orm.yml, Job.orm.yml and Affiliate.orm.yml.

IbwJobeetBundleEntityCategory:
    type: entity
    table: category
    id:
        id:
            type: integer
            generator: { strategy: AUTO }
    fields:
        name:
            type: string
            length: 255
            unique: true
    oneToMany:
        jobs:
            targetEntity: Job
            mappedBy: category
    manyToMany:
        affiliates:
            targetEntity: Affiliate
            mappedBy: categories
IbwJobeetBundleEntityJob:
    type: entity
    table: job
    id:
        id:
            type: integer
            generator: { strategy: AUTO }
    fields:
        type:
            type: string
            length: 255
            nullable: true
        company:
            type: string
            length: 255
        logo:
            type: string
            length: 255
            nullable: true
        url:
            type: string
            length: 255
            nullable: true
        position:
            type: string
            length: 255
        location:
            type: string
            length: 255
        description:
            type: text
        how_to_apply:
            type: text
        token:
            type: string
            length: 255
            unique: true
        is_public:
            type: boolean
            nullable: true
        is_activated:
            type: boolean
            nullable: true
        email:
            type: string
            length: 255
        expires_at:
            type: datetime
        created_at:
            type: datetime
        updated_at:
            type: datetime
            nullable: true
    manyToOne:
        category:
            targetEntity: Category
            inversedBy: jobs
            joinColumn:
                name: category_id
                referencedColumnName: id
    lifecycleCallbacks:
        prePersist: [ setCreatedAtValue ]
        preUpdate: [ setUpdatedAtValue ]
IbwJobeetBundleEntityAffiliate:
    type: entity
    table: affiliate
    id:
        id:
            type: integer
            generator: { strategy: AUTO }
    fields:
        url:
            type: string
            length: 255
        email:
            type: string
            length: 255
            unique: true
        token:
            type: string
            length: 255
        is_active:
            type: boolean
            nullable: true
        created_at:
            type: datetime
    manyToMany:
        categories:
            targetEntity: Category
            joinTable:
                name: category_affiliate
                joinColumns:
                    affiliate_id:
                        referencedColumnName: id
                inverseJoinColumns:
                    category_id:
                        referencedColumnName: id
    lifecycleCallbacks:
        prePersist: [ setCreatedAtValue ]

The ORM

Now Doctrine can generate the classes that define our objects for us with the command:

php app/console doctrine:generate:entities IbwJobeetBundle

If you take a look into Entity directory from IbwJobeetBundle, you will find the newly generated classes in there: Category.php, Job.php and Affiliate.php. Open Job.php and set the created_at and updated_at values as below:

// ...

    /**
     * @ORMPrePersist
     */
    public function setCreatedAtValue()
    {
        if(!$this->getCreatedAt()) {
            $this->created_at = new DateTime();
        }
    }

    /**
     * @ORMPreUpdate
     */
    public function setUpdatedAtValue()
    {
        $this->updated_at = new DateTime();
    }

You will do the same for created_at value of the Affiliate class:

// ...

    /**
     * @ORMPrePersist
     */
    public function setCreatedAtValue()
    {
        $this->created_at = new DateTime();
    }

// ...

This will make Doctrine to set the created_at and updated_at values when saving or updating objects. This behaviour was defined in the Affiliate.orm.yml and Job.orm.yml files listed above.

We will also ask Doctrine to create our database tables with the command below:

php app/console doctrine:schema:update --force

This task should only be used during the development. For a more robust method of systematically updating your production database, read about Doctrine migrations.

The tables have been created in the database but there is no data in them. For any web application, there are three types of data: initial data (this is needed for the application to work, in our case we will have some initial categories and an admin user), test data (needed for the application to be tested) and user data (created by users during the normal life of the application).

To populate the database with some initial data, we will use DoctrineFixturesBundle. To setup this bundle, we have to follow the next steps:

1. Add the following to your composer.json file, in the require section:

// ...
    "require": {
        // ...
        "doctrine/doctrine-fixtures-bundle": "dev-master",
        "doctrine/data-fixtures": "dev-master"
    },

// ...

2. Update the vendor libraries:

php composer.phar update

3. Register the bundle DoctrineFixturesBundle in app/AppKernel.php:

// ...

public function registerBundles()
{
    $bundles = array(
        // ...
        new DoctrineBundleFixturesBundleDoctrineFixturesBundle()
    );

    // ...
}

Now that everything is set up, we will create some new classes to load data in a new folder, named src/Ibw/JobeetBundle/DataFixtures/ORM, in our bundle:

<?php
namespace IbwJobeetBundleDataFixturesORM;

use DoctrineCommonPersistenceObjectManager;
use DoctrineCommonDataFixturesAbstractFixture;
use DoctrineCommonDataFixturesOrderedFixtureInterface;
use IbwJobeetBundleEntityCategory;

class LoadCategoryData extends AbstractFixture implements OrderedFixtureInterface
{
    public function load(ObjectManager $em)
    {
        $design = new Category();
        $design->setName('Design');

        $programming = new Category();
        $programming->setName('Programming');

        $manager = new Category();
        $manager->setName('Manager');

        $administrator = new Category();
        $administrator->setName('Administrator');

        $em->persist($design);
        $em->persist($programming);
        $em->persist($manager);
        $em->persist($administrator);
        $em->flush();

        $this->addReference('category-design', $design);
        $this->addReference('category-programming', $programming);
        $this->addReference('category-manager', $manager);
        $this->addReference('category-administrator', $administrator);
    }

    public function getOrder()
    {
        return 1; // the order in which fixtures will be loaded
    }
}

 

<?php
namespace IbwJobeetBundleDataFixturesORM;

use DoctrineCommonPersistenceObjectManager;
use DoctrineCommonDataFixturesAbstractFixture;
use DoctrineCommonDataFixturesOrderedFixtureInterface;
use IbwJobeetBundleEntityJob;

class LoadJobData extends AbstractFixture implements OrderedFixtureInterface
{
    public function load(ObjectManager $em)
    {
         $job_sensio_labs = new Job();
         $job_sensio_labs->setCategory($em->merge($this->getReference('category-programming')));
         $job_sensio_labs->setType('full-time');
         $job_sensio_labs->setCompany('Sensio Labs');
         $job_sensio_labs->setLogo('sensio-labs.gif');
         $job_sensio_labs->setUrl('http://www.sensiolabs.com/');
         $job_sensio_labs->setPosition('Web Developer');
         $job_sensio_labs->setLocation('Paris, France');
         $job_sensio_labs->setDescription('You've already developed websites with symfony and you want to work with Open-Source technologies. You have a minimum of 3 years experience in web development with PHP or Java and you wish to participate to development of Web 2.0 sites using the best frameworks available.');
         $job_sensio_labs->setHowToApply('Send your resume to fabien.potencier [at] sensio.com');
         $job_sensio_labs->setIsPublic(true);
         $job_sensio_labs->setIsActivated(true);
         $job_sensio_labs->setToken('job_sensio_labs');
         $job_sensio_labs->setEmail('job@example.com');
         $job_sensio_labs->setExpiresAt(new DateTime('+30 days'));
         $job_extreme_sensio = new Job();
         $job_extreme_sensio->setCategory($em->merge($this->getReference('category-design')));
         $job_extreme_sensio->setType('part-time');
         $job_extreme_sensio->setCompany('Extreme Sensio');
         $job_extreme_sensio->setLogo('extreme-sensio.gif');
         $job_extreme_sensio->setUrl('http://www.extreme-sensio.com/');
         $job_extreme_sensio->setPosition('Web Designer');
         $job_extreme_sensio->setLocation('Paris, France');
         $job_extreme_sensio->setDescription('Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in.');
         $job_extreme_sensio->setHowToApply('Send your resume to fabien.potencier [at] sensio.com');
         $job_extreme_sensio->setIsPublic(true);
         $job_extreme_sensio->setIsActivated(true);
         $job_extreme_sensio->setToken('job_extreme_sensio');
         $job_extreme_sensio->setEmail('job@example.com');
         $job_extreme_sensio->setExpiresAt(new DateTime('+30 days'));

         $em->persist($job_sensio_labs);
         $em->persist($job_extreme_sensio);
         $em->flush();
    }

    public function getOrder()
    {
        return 2; // the order in which fixtures will be loaded
    }
}

Once your fixtures have been written, you can load them via the command line by using thedoctrine:fixtures:load command:

php app/console doctrine:fixtures:load

Now, if you check your database, you should see the data loaded into tables.

See it in the browser

If you run the command below, it will create a new controller src/Ibw/JobeetBundle/Controllers/JobController.php with actions for listing, creating, editing and deleting jobs (and their corresponding templates, form and routes):

php app/console doctrine:generate:crud --entity=IbwJobeetBundle:Job --route-prefix=ibw_job --with-write --format=yml

After running this command, you will need to do some configurations the prompter requires you to. So just select the default answers for them.

To view this in the browser, we must import the new routes that were created in src/Ibw/JobeetBundle/Resources/config/routing/job.yml into our bundle main routing file:

IbwJobeetBundle_job:
        resource: "@IbwJobeetBundle/Resources/config/routing/job.yml"
        prefix:   /job

# ...

We will also need to add a _toString() method to our Category class to be used by the category drop down from the edit job form:

// ...

public function __toString()
{
    return $this->getName() ? $this->getName() : "";
}

// ...

Clear the cache:

php app/console cache:clear --env=dev
php app/console cache:clear --env=prod

You can now test the job controller in a browser: http://jobeet.local/job/ or, in development environment, http://jobeet.local/app_dev.php/job/ .

Day 3 - index_page

You can now create and edit jobs. Try to leave a required field blank, or try to enter invalid data. That’s right, Symfony has created basic validation rules by introspecting the database schema.

That’s all. Today, we have barely written PHP code but we have a working web module for the job model, ready to be tweaked and customized. Tomorrow, we will get familiar with the controller and the view. See you next time!

Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.


Symfony2 Jobeet Day 2: The Project

* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.
We have not written a single line of code yet, but, in Day 1, we setup the environment and created an empty Symfony project.

This day is about the project specifications. Before diving into the code head-first, let’s describe the project a bit more. The following sections describe the features we want to implement in the first version/iteration of the project with some simple stories.

User Stories

The Jobeet website will have four type of users: admin (owns and manages the website), user (visits the website looking for a job), poster (visits the website to post jobs) and affiliate (re-publishes jobs on his website).

In the original tutorial, we had to make two applications, the frontend, where the users interact with the website, and the backend, where admins manage the website. Using Symfony 2.3.2, we would not do this anymore. We will have only one application and, in it, a separate secured section for admins.

Story F1: On the homepage, the user sees the latest active jobs

When a user comes to Jobeet website, he sees a list of active jobs. The jobs are sorted by category and then by publication date – newer jobs first. For each job, only the location, the position available and the company are displayed.

For each category, the list shows the first 10 jobs and a link that allows to list all the jobs for a given category (Story F2).

On the homepage, the user can refine the job list (Story F3) or post a new job (Story F5).

Story F2: A user can ask for all the jobs in a given category

When a user clicks on a category name or on a “more jobs” link on the homepage, he sees all the jobs for this category sorted by date.

The list is paginated with 20 jobs per page.

Story F3: A user refines the list with some keywords

The user can enter some keywords to refine his search. Keywords can be words found in the location, the position, the category or the company fields.

Story F4: A user clicks on a job to see more detailed information

The user can select a job from a list to see more detailed information.

Story F5: A user posts a job

A user can post a job. A job is made of several pieces of information:

  • Company
  • Type (full-time, part-time or freelance)
  • Logo (optional)
  • URL (optional)
  • Position
  • Location
  • Category (the user chooses in a list of possible categories)
  • Job description (URLs and emails are automatically linked)
  • How to apply (URLs and emails are automatically linked)
  • Public (wether the job can also be published on affiliate websites)
  • Email (email of poster)

The process has only two steps: first, the user fills in the form with all the needed information to describe the job, then validates the information by previewing the final job page.

There is no need to create an acount to post a job. A job can be modified afterwards thanks to a specific URL (protected by a token given to the user when the job is created).

Each job post is online for 30 days (this is configurable by admin). A user can come back to re-activate or extend the validity of the job for an extra 30 days, but only when the job expires in less than 5 days.

Story F6: A user applies to become an affiliate

A user needs to apply to become an affiliate and be authorized to use Jobeet API. To apply, he must give the following information:

  • Name
  • Email
  • Website URL

The affiliate account must be activated by the admin (Story B3). Once activated, the affiliate receives a token to use with the API via email.

Story F7: An affiliate retrieves the current active job list

An affiliate can retrieve the current job list by calling the API with his affiliate token. The list can be returned in the XML, JSON or YAML format. The affiliate can limit the number of jobs to be returned and, also, refine his query by specifying a category.

Story B1: An admin configures the website

An admin can edit the categories available on the website.

Story B2: An admin manages the jobs

An admin can edit and remove any posted job.

Story B3: An admin manages the affiliates

The admin can create or edit affiliates. He is responsible for activating an affiliate and can also disable one. When the admin activates a new affiliate, the system creates a unique token to be used by the affiliate.

As a developer, you never start coding from the first day. Firstly, you need to gather the requirements of your project and understand how your project is supposed to work. That’s what you have done today. See you tomorrow!

Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.


Symfony2 Jobeet Day 1: Starting up the Project

* This article is part of the original Jobeet Tutorial, created by Fabien Potencier, for Symfony 1.4.

What is Jobeet?

Jobeet is an Open-Source job board software which provides you day-by-day tutorials, that will help you learn the latest Web Technolgy, Symfony 2.3.2 (for those who don’t know yet, Symfony is a framework for PHP).

Each chapter/day is meant to last about one hour, and will be the occasion to learn Symfony by coding a real website, from start to finish.

Every day, new features will be added to the application and we’ll take advantage of this development to introduce you to new Symfony functionalities, as well as good practices in Symfony web development.

Today, being your first day, you won’t be writing any code. Instead, you will setup a working development environment.

Setting up the working development environment

First of all, you need to check that your computer has a friendly working environment for web development. We will use Ubuntu 12.04 LTS Server installed in a VMware Player virtual machine. At a minimum, you need a web server (Apache, for instance), a database engine (MySQL) and PHP 5.3.3 or later.

1. Install Apache, your web server:

sudo apt-get install apache2

and enable Apache mod-rewrite:

sudo a2enmod rewrite

2, Install the MySQL Server:

sudo apt-get install mysql-server mysql-client

3. Install PHP, the server scripting language

sudo apt-get install php5 libapache2-mod-php5 php5-mysql

4. Install Intl extension:

sudo apt-get install php5-intl

5. Now, you need to restart Apache service:

sudo service apache2 restart

Download and install Symfony 2.3.2

The first thing to do is to prepare a directory on your web server where you want to install the new project. Let’s call it jobeet: /var/www/jobeet.

mkdir /var/www/jobeet

We have a directory prepared, but what to put in it? Go to http://symfony.com/download, choose Symfony Standard 2.3.2 without vendors and download it. Now, unzip the files inside the Symfony directory to your prepared directory, jobeet.

Updating Vendors

At this point, you’ve downloaded a fully-functional Symfony project in which you’ll start to develop your own application. A Symfony project depends on a number of external libraries. These are downloaded into the vendor/ directory of your project via a library called Composer.

Composer is a dependency management library for PHP, which you can use to download the Symfony 2.3.2 Standard Edition. Start by downloading Composer onto your jobeet directory:

curl -s https://getcomposer.org/installer | php

If you don’t have curl extension installed, you can install it using this command:

sudo apt-get install curl

Next, type the following command to start downloading all the necessary vendor libraries:

php composer.phar install

Web Server Configuration

A good web practice is to put under the web root directory only the files that need to be accessed by a web browser, like stylesheets, JavaScripts and images. By default, it’s recommended to store these files under the web/ sub-directory of a symfony project.

To configure Apache for your new project, you will create a virtual host. In order to do that, go to your terminal and type in the next command :

sudo nano /etc/apache2/sites-available/jobeet.local

Now, a file named jobeet.local is created. Put the following inside that file, then hit Control – O and Enter to save it, then Control – X to exit the editor.

<VirtualHost *:80>
    ServerName jobeet.local
    DocumentRoot /var/www/jobeet/web
    DirectoryIndex app.php
    ErrorLog /var/log/apache2/jobeet-error.log
    CustomLog /var/log/apache2/jobeet-access.log combined
    <Directory "/var/www/jobeet/web">
        AllowOverride All
        Allow from All
     </Directory>
</VirtualHost>

The domain name jobeet.local used in the Apache configuration has to be declared locally. If you run a Linux system, it has to be done in the /etc/hosts file. If you run Windows, this file is located in the C:WindowsSystem32driversetc directory. Add the following line:

127.0.0.1 jobeet.local

Replace 127.0.0.1 with the ip of your web server machine in case you are working on a remote server.

If you want this to work, you need to enable the newly created virtual host and restart your Apache. So go to your terminal and type:

sudo a2ensite jobeet.local
sudo service apache2 restart

Symfony comes with a visual server configuration tester to help make sure your Web server and PHP are correctly configured to use Symfony. Use the following URL to check your configuration:

http://jobeet.local/config.php

sf2-config (1)

If you don’t run this from your localhost, you should locate and open web/config.php file and comment the lines that restrict the access outside localhost:
if (!isset($_SERVER['HTTP_HOST'])) {
    exit('This script cannot be run from the CLI. Run it from a browser.');
}

/*
if (!in_array(@$_SERVER['REMOTE_ADDR'], array(
    '127.0.0.1',
    '::1',
))) {
    header('HTTP/1.0 403 Forbidden');
    exit('This script is only accessible from localhost.');
}
*/

// ...

Do the same for web/app_dev.php:

use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentDebugDebug;

// If you don't want to setup permissions the proper way, just uncomment the following PHP line
// read http://symfony.com/doc/current/book/installation.html#configuration-and-setup for more information
//umask(0000);

// This check prevents access to debug front controllers that are deployed by accident to production servers.
// Feel free to remove this, extend it, or make something more sophisticated.
/*
if (isset($_SERVER['HTTP_CLIENT_IP'])
    || isset($_SERVER['HTTP_X_FORWARDED_FOR'])
    || !in_array(@$_SERVER['REMOTE_ADDR'], array('127.0.0.1', 'fe80::1', '::1'))
) {
    header('HTTP/1.0 403 Forbidden');
    exit('You are not allowed to access this file. Check '.basename(__FILE__).' for more information.');
}
*/

$loader = require_once __DIR__.'/../app/bootstrap.php.cache';
Debug::enable();

require_once __DIR__.'/../app/AppKernel.php';

// ...

Probably, you will get all kind of requirements when you go to config.php. Below, is a list of things to do for not getting all those “warnings”.

1. Change the permissions of app/cache and app/logs:

sudo chmod -R 777 app/cache
sudo chmod -R 777 app/logs
sudo setfacl -dR -m u::rwX app/cache app/logs

Install ACL if you don’t have it yet:

sudo apt-get install acl

2. Set the date.timezone setting in php.ini

date.timezone = Europe/Bucharest
sudo nano /etc/php5/apache2/php.ini
Find the date.timezone setting for [date] section and set it to your timezone. After that, erase “;”, placed at the beginning of the line.
3. Set the short_open_tag setting to off in the same php.ini file
short_open_tag
  Default Value: Off

4. Install and enable a PHP Accelerator (APC recommended)

sudo apt-get install php-apc
sudo service apache2 restart

After restarting Apache, open a browser window and type in http://jobeet.local/app_dev.php. You should see the following page:

Day 1 - SF_welcome

Symfony2 Console

Symfony2 comes with the console component tool that you will use for different tasks. To see a list of things it can do for you type at the command prompt:

php app/console list

Creating the Application Bundle

What exactly is a bundle?

Is similar to a plugin in other software, but even better. The key difference is that everything is a bundle in Symfony 2.3.2, including both core framework functionality and the code written for your application.

A bundle is a structured set of files within a directory that implement a single feature.

Tips: A bundle can live anywhere as long as it can be autoloaded (app/autoload.php).

You can read more here: http://symfony.com/doc/current/book/page_creation.html#the-bundle-system – The Bundle System.

Creating a basic bundle skeleton

Run the following command to start the Symfony’s bundle generator:

php app/console generate:bundle --namespace=Ibw/JobeetBundle

The generator will ask you some questions before generating the bundle. Here are the questions and answers (all, except one, are the default answers):

Bundle name [IbwJobeetBundle]: IbwJobeetBundle
Target directory [/var/www/jobeet/src]: /var/www/jobeet/src
Configuration format (yml, xml, php, or annotation) [yml]: yml
Do you want to generate the whole directory structure [no]? yes
Do you confirm generation [yes]? yes
Confirm automatic update of your Kernel [yes]? yes
Confirm automatic update of the Routing [yes]? yes

Clear the cache after generating the new bundle with:

php app/console cache:clear --env=prod
php app/console cache:clear --env=dev

The new Jobeet bundle can be now found in the src directory of your project: src/Ibw/JobeetBundle. The bundle generator made a DefaultController with an index action. You can access this in your browser: http://jobeet.local/hello/jobeet or http://jobeet.local/app_dev.php/hello/jobeet.

How to remove the AcmeDemoBundle

The Symfony 2.3.2 Standard Edition comes with a complete demo that lives inside a bundle called AcmeDemoBundle. It is a great boilerplate to refer to while starting a project, but you’ll probably want to eventually remove it.

1. Type the command to delete Acme directory:

rm -rf /var/www/jobeet/src/Acme

2. Go to: /var/www/jobeet/app/AppKernel.php and delete:

// ...

$bundles[] = new AcmeDemoBundleAcmeDemoBundle();

// ...

and now delete from app/config/routing_dev.yml:

# ... 

# AcmeDemoBundle routes (to be removed)
_acme_demo:
    resource: "@AcmeDemoBundle/Resources/config/routing.yml"

3. Finally, clear the cache.

The Environments

Symfony 2.3.2 has different environments. If you look in the project’s web directory, you will see two php files: app.php and app_dev.php. These files are called front controllers; all requests to the application are made through them. The app.php file is for production environment and app_dev.php is used by web developers when they work on the application in the development environment. The development environment will prove very handy because it will show you all the errors and warnings and the Web Debug Toolbar – the developer’s best friend.

That’s all for today. See you on the next day of this tutorial, when we will talk about what exactly the Jobeet website will be about!

Creative Commons License
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.