This website uses cookies

Our website, platform and/or any sub domains use cookies to understand how you use our services, and to improve both your experience and our marketing relevance.

📣 Try the fastest hosting platform with pay-as-you-go pricing & 24/7 expert support! MIGRATE NOW →

Let’s Build a Project Time Tracker with Symfony and VueJS

Updated on July 15, 2021

19 Min Read

As developers, we often love to keep track on the amount of time spent implementing a new feature, fixing bugs or generally carrying out a particular task during development. This has proven to be one of the ways to improve productivity.

Time-tracker-app is a project that has help boost my productivity and I thought the need to share the process of implementing it as well. With this mini app, you can add more than one project, specify the tasks you would love to carry out and eventually track how much time you have spent.

This project was built using one of my favorite PHP frameworks, Symfony, with its frontend powered fully by Vue.js, a progressive JavaScript framework.

Together, we will build this project and once completed, we should have :

To get the best out of this tutorial, a reasonable knowledge of PHP, JavaScript and Object Oriented Programming is advised.

This article will not cover or detailed the process of setting up Symfony and Vue.js. You can follow this article on Getting Started with Symfony and VueJS.

Host PHP Websites with Ease [Starts at $10 Credit]

  • Free Staging
  • Free backup
  • PHP 8.0
  • Unlimited Websites

TRY NOW

Getting Started

A composer can be used to quickly set up a new local Symfony project. Alternatively, you can easily set up a Cloudways account to launch a server and follow this well-written tutorial to install Symfony 4.

Open up a terminal and create a new Symfony project by running the command below:

composer create-project symfony/website-skeleton time-tracker-app.

This will create a skeleton project that is optimized for traditional web applications with the required dependencies already downloaded into it. In addition, it has basic directories and files for getting started with a web project.

You Might Also Like: How to Connect PHP SFTP With phpseclib in Symfony

Running Application

If you are running locally, spin up the inbuilt best PHP hosting server by changing directory into the newly created project and run the server as shown below:

cd time-tracker-app

php bin/console server:run

Open your browser and navigate to http://localhost:8000. By now, you should see a welcome page.

User Management

In order to ensure that each project created is tied to a unique user, setting up a user management process is inevitable. We will do this by creating the entity for a user and then implement registration and login process.

Configure and Create Database

To start, open up the .env file in the project’s root directory, you should see something similar to the following:

###> doctrine/doctrine-bundle ###

DATABASE_URL=mysql://db_user:[email protected]:3306/db_name

###< doctrine/doctrine-bundle ###

Update the values with the credentials for your MySQL database.

  • Change db_user to YOUR_DATABASE_USERNAME
  • Change db_password to YOUR_DATABASE_PASSWORD
  • db_name to YOUR_DATABASE_NAME
  • The database host by default is 127.0.0.1 and with a database port of 3306. You can leave this values as it is.
  • Configuring this credentials would also enable you to run a single command php bin/console doctrine:database:create to create the database with the name db_name, this will be covered later in the tutorial.

Set up Controllers and Entity

Just as you would have it in any application with a lot of request and responses, we will set up a couple of controllers to handle actions and redirect or return the appropriate response required for this application to function properly. We already have the maker bundle generated earlier on, let us make use of it to automatically generate the controllers.

Default Controller

This controller handles the route to the landing page and renders the view (i.e `index.html.twig file).  Run the make controller command to generate this

php bin/console make:controller DefaultController

Locate src/Controller/DefaultController.php and update the contents as shown here :

<?php



namespace App\Controller;



use Symfony\Component\Routing\Annotation\Route;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;



class DefaultController extends Controller

{

   /**

    * @Route("/", name="default")

    */

   public function index()

   {

       return $this->render('default/index.html.twig');

   }

}

Registration Controller

As pointed out earlier, it is important for users to register before creating projects.

php bin/console make:controller RegistrationController

In ./src/Controller/RegistrationController.php paste the following.

<?php



namespace App\Controller;



use App\Entity\User;

use App\Form\UserType;

use Doctrine\ORM\EntityManagerInterface;

use Symfony\Component\HttpFoundation\Request;

use Symfony\Component\Routing\Annotation\Route;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;

use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;



class RegistrationController extends Controller

{

   /**

    * @var EntityManagerInterface

    */

   private $entityManager;



   /**

    * @var \Doctrine\Common\Persistence\ObjectRepository

    */

   private $userRepository;



   public function __construct(EntityManagerInterface $entityManager)

   {

       $this->entityManager = $entityManager;

       $this->userRepository = $entityManager->getRepository('App:User');

   }



   /**

    * @Route("/register", name="user_registration")

    */

   public function registerAction(Request $request, UserPasswordEncoderInterface $passwordEncoder)

   {



       $user = new User();

       $form = $this->createForm(UserType::class, $user);

       $form->handleRequest($request);

       if ($form->isSubmitted() && $form->isValid()) {

           $password = $passwordEncoder->encodePassword($user, $user->getPlainPassword());

           $user->setPassword($password);

           // save the User

           $this->updateDatabase($user);



           return $this->redirectToRoute('home');

       }

       return $this->render('registration/register.html.twig', [

           'form' => $form->createView(),

       ]);

   }

   function updateDatabase($object)

   {

       $this->entityManager->persist($object);

       $this->entityManager->flush();

   }

}

The controller above basically handles the registration process and redirect users to the login page once completed. A closer look at the controller, you would notice the addition of a UserType class. This is used to validate user’s input and eventually for submitting the registration form.

We will leverage on Symfony’s standard of building a form by creating a separate form class. Locate the src folder and create a new folder called Form. Within that Form folder, create a new file named UserType.php. Fill this file with :

UserType

<?php



namespace App\Form;



use App\Entity\User;

use Symfony\Component\Form\AbstractType;

use Symfony\Component\Form\Extension\Core\Type\EmailType;

use Symfony\Component\Form\Extension\Core\Type\PasswordType;

use Symfony\Component\Form\Extension\Core\Type\RepeatedType;

use Symfony\Component\Form\Extension\Core\Type\SubmitType;

use Symfony\Component\Form\Extension\Core\Type\TextType;

use Symfony\Component\Form\FormBuilderInterface;

use Symfony\Component\OptionsResolver\OptionsResolver;



class UserType extends AbstractType

{

   public function buildForm(FormBuilderInterface $builder, array $options)

   {

       $builder

           ->add('email', EmailType::class,['attr' => ['class' => 'form-control']])

           ->add('username', TextType::class,['attr' => ['class' => 'form-control']])

           ->add('plainPassword', RepeatedType::class, [

               'type' => PasswordType::class,

               'first_options' => ['label' => 'Password'],

               'second_options' => ['label' => 'Repeat Password'],

               'options' => ['attr' => ['class' => 'form-control']]

           ])

           ->add('submit', SubmitType::class,[

                   'attr' => ['class' => 'form-control btn-success'],

                   'label' => 'Register']

           )

       ;

   }



   public function configureOptions(OptionsResolver $resolver)

   {

       $resolver->setDefaults([

           // uncomment if you want to bind to a class

           'data_class' => User::class,

       ]);

   }

}

SecurityController

To authenticate and authorize a user before start creating projects. Let’s create a Security controller. Feel free to give it any name that you want.

php bin/console make:controller SecurityController

 

<?php



namespace App\Controller;



use Symfony\Component\HttpFoundation\Request;

use Symfony\Component\Routing\Annotation\Route;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;

use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;



class SecurityController extends Controller

{

   /**

    * @Route("/login", name="login")

    */

   public function login(Request $request, AuthenticationUtils $authUtils)

   {

       $error = $authUtils->getLastAuthenticationError();



       $lastUsername = $authUtils->getLastUsername();



       return $this->render('security/login.html.twig', [

           'last_name' => $lastUsername,

           'error' => $error,

       ]);

   }

}




User Entity

A user entity will be set up by running :

php bin/console make:entity User

once completed, open up the file and update as shown below:



<?php



namespace App\Entity;



use Doctrine\ORM\Mapping as ORM;

use Symfony\Component\Validator\Constraints as Assert;

use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

use Symfony\Component\Security\Core\User\UserInterface;



/**

* @ORM\Entity(repositoryClass="App\Repository\UserRepository")

* @UniqueEntity(fields="email", message="Email already taken")

* @UniqueEntity(fields="username", message="Username already taken")

*/

class User implements UserInterface, \Serializable

{

   /**

    * @ORM\Id

    * @ORM\GeneratedValue

    * @ORM\Column(type="integer")

    */

   private $id;



   /**

    * @ORM\Column(type="string", length=255, unique=true)

    * @Assert\NotBlank()

    * @Assert\Email()

    */

   private $email;



   /**

    * @ORM\Column(type="string", length=100, unique=true)

    * @Assert\NotBlank()

    */

   private $username;



   /**

    * @Assert\NotBlank()

    * @Assert\Length(max="4096")

    */

   private $plainPassword;



   /**

    * @ORM\Column(type="string", length=64)

    */

   private $password;




   /**

    * @ORM\Column(name="is_active", type="boolean")

    */

   private $isActive;



   /**

    * @ORM\OneToMany(targetEntity="App\Entity\Project", mappedBy="user")

    */

   private $project;



   /**

    * @ORM\OneToMany(targetEntity="App\Entity\Timer", mappedBy="user")

    */

   private $timer;




   public function __construct()

   {

       $this->isActive = true;

   }



   /**

    * Returns the username used to authenticate the user.

    *

    * @return string The username

    */

   public function getUsername()

   {

       return $this->username;

   }



   /**

    * @param mixed $username

    */

   public function setUsername($username)

   {

       $this->username = $username;

   }




   /**

    * @return mixed

    */

   public function getId()

   {

       return $this->id;

   }



   /**

    * @param mixed $id

    */

   public function setId($id)

   {

       $this->id = $id;

   }



   /**

    * @return mixed

    */

   public function getEmail()

   {

       return $this->email;

   }



   /**

    * @param mixed $email

    */

   public function setEmail($email)

   {

       $this->email = $email;

   }



   /**

    * @return mixed

    */

   public function getisActive()

   {

       return $this->isActive;

   }

   /**

    * @param mixed $isActive

    */

   public function setIsActive($isActive)

   {

       $this->isActive = $isActive;

   }



   /**

    * @return mixed

    */

   public function getProject()

   {

       return $this->project;

   }



   /**

    * @param mixed $project

    */

   public function setProject($project)

   {

       $this->project = $project;

   }



   /**

    * @return mixed

    */

   public function getTimer()

   {

       return $this->timer;

   }



   /**

    * @param mixed $timer

    */

   public function setTimer($timer)

   {

       $this->timer = $timer;

   }

   /**

    * Returns the salt that was originally used to encode the password.

    *

    * This can return null if the password was not encoded using a salt.

    *

    * @return string|null The salt

    */

   public function getSalt()

   {

       return null;

   }



   /**

    * @return mixed

    */

   public function getPlainPassword()

   {

       return $this->plainPassword;

   }



   /**

    * @param mixed $password

    */

   public function setPlainPassword($password)

   {

       $this->plainPassword = $password;

   }

   /**

    * Returns the password used to authenticate the user.

    *

    * @return string The password

    */

   public function getPassword()

   {

       return $this->password;

   }



   /**

    * @param mixed $password

    */

   public function setPassword($password)

   {

       $this->password = $password;

   }

   /**

    * @return array

    */

   public function getRoles()

   {

       return array('ROLE_USER');

   }

   public function eraseCredentials()

   {

   }



   public function serialize()

   {

       return serialize(array(

           $this->id,

           $this->username,

           $this->password,

       ));

   }

   public function unserialize($serialized)

   {

       list(

           $this->id,

           $this->username,

           $this->password,

           ) = unserialize($serialized);

   }



}

Finally, replace the contents of your config/packages/security.yaml file with :

security:

   encoders:

       App\Entity\User:

           algorithm: bcrypt

   # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers

   providers:

       our_db_provider:

           entity:

                class: App\Entity\User

                property: username

   firewalls:

       dev:

           pattern: ^/(_(profiler|wdt)|css|images|js)/

           security: false

       main:

#            pattern: ^/

           anonymous: ~

#            http_basic: ~

           provider: our_db_provider

#            anonymous: true



           # activate different ways to authenticate



           # http_basic: true

           # https://symfony.com/doc/current/security.html#a-configuring-how-your-users-will-authenticate



           form_login:

                 login_path: login

                 check_path: login

                 default_target_path: /home

#        secured_area:

#            anonymous: ~

           logout:

               path: /logout

               target: /



   # Easy way to control access for large sections of your site

   # Note: Only the *first* access control that matches will be used

   access_control:

        - { path: ^/home, roles: ROLE_USER }

You Might Also Like: Create Token Based API Authentication in Symfony

The configuration file above, amongst other things, helps to set up how to load users from the database, configure the firewall, setup the login and logout path, and finally added an access control.

Add this to config/routes.yaml file.

logout:

   path: /logout

Project Controller

The next file will be the project controller

php bin/console make:controller ProjectController

Now open ./src/controller/ProjectController.php and paste the following code:

<?php



namespace App\Controller;



use App\Entity\Project;

use Doctrine\ORM\EntityManagerInterface;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;

use Symfony\Component\HttpFoundation\JsonResponse;

use Symfony\Component\HttpFoundation\Request;

use Symfony\Component\HttpFoundation\Response;

use Symfony\Component\Routing\Annotation\Route;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;

use Symfony\Component\Serializer\Encoder\JsonEncoder;

use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;

use Symfony\Component\Serializer\Serializer;

use Symfony\Component\Validator\Constraints\DateTime;



class ProjectController extends Controller

{



   /**

    * @var EntityManagerInterface

    */

   private $entityManager;



   /**

    * @var \Doctrine\Common\Persistence\ObjectRepository

    */

   private $projectRepository;



   /**

    * ProjectController constructor.

    * @param EntityManagerInterface $entityManager

    */

   public function __construct(EntityManagerInterface $entityManager)

   {

       $this->entityManager = $entityManager;

       $this->projectRepository = $entityManager->getRepository('App:Project');

   }



   /**

    * @Route("/projects", name="project")

    */

   public function index()

   {

       $projects = $this->projectRepository->findByUser($this->getUser()->getId());

       $jsonContent = $this->serializeObject($projects);

       return new Response($jsonContent, Response::HTTP_OK);



   }




   /**

    * @param Request $request

    * @return Response

    * @Route("/projects/create", name="create_project")

    */

   public function saveProjects(Request $request)

   {

       $content = json_decode($request->getContent(), true);



       if($content['name']) {



           $project = new Project();

           $project->setUser($this->getUser());

           $project->setName($content['name']);

           $project->setTimers([]);

           $project->setCreatedAt(new \DateTime());

           $project->setUpdatedAt(new \DateTime());

           $this->updateDatabase($project);



           // Serialize object into Json format

           $jsonContent = $this->serializeObject($project);



           return new Response($jsonContent, Response::HTTP_OK);

       }



       return new Response('Error', Response::HTTP_NOT_FOUND);



   }

   public function serializeObject($object)

   {

       $encoders = new JsonEncoder();

       $normalizers = new ObjectNormalizer();



       $normalizers->setCircularReferenceHandler(function ($obj) {

           return $obj->getId();

       });

       $serializer = new Serializer(array($normalizers), array($encoders));



       $jsonContent = $serializer->serialize($object, 'json');



       return $jsonContent;

   }

   public function updateDatabase($object)

   {

       $this->entityManager->persist($object);

       $this->entityManager->flush();

   }

}

Here, we fetched all the projects belonging to the current authenticated user. In the saveProjects method, we grabbed the content of the project posted from the frontend and saved into the database.

Project Entity

A Project object to represent each project that will be created

php bin/console make:entity Project

Open ./src/Entity/Project and update the content with :

<?php



namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;



/**

* @ORM\Entity(repositoryClass="App\Repository\ProjectRepository")

*/

class Project

{

   /**

    * @ORM\Id

    * @ORM\GeneratedValue

    * @ORM\Column(type="integer")

    */

   private $id;



   /**

    * @ORM\Column(type="string", name="name", length=20)

    */

   private $name;



   /**

    * @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="project")

    * @ORM\JoinColumn(nullable=true)

    */

   private $user;



   /**

    * @ORM\OneToMany(targetEntity="App\Entity\Timer", mappedBy="project")

    */

   private $timers;



   /**

    * @ORM\Column(type="datetime", name="created_at")

    */

   private $createdAt;



   /**

    * @ORM\Column(type="datetime", name="updated_at")

    */

   private $updatedAt;



   /**

    * @return mixed

    */

   public function getId()

   {

       return $this->id;

   }



   /**

    * @param mixed $id

    */

   public function setId($id)

   {

       $this->id = $id;

   }



   /**

    * @return mixed

    */

   public function getName()

   {

       return $this->name;

   }



   /**

    * @param mixed $name

    */

   public function setName($name)

   {

       $this->name = $name;

   }




   /**

    * @param mixed $user

    */

   public function setUser(User $user)

   {

       $this->user = $user;

   }



   /**

    * @return mixed

    */



   public function getTimers()

   {

       return $this->timers;

   }



   /**

    * @param mixed $timers

    */

   public function setTimers($timers)

   {

       $this->timers = $timers;

   }




   /**

    * @return mixed

    */

   public function getCreatedAt()

   {

       return $this->createdAt;

   }



   /**

    * @param mixed $createdAt

    */

   public function setCreatedAt($createdAt)

   {

       $this->createdAt = $createdAt;

   }



   /**

    * @return mixed

    */

   public function getUpdatedAt()

   {

       return $this->updatedAt;

   }



   /**

    * @param mixed $updatedAt

    */

   public function setUpdatedAt($updatedAt)

   {

       $this->updatedAt = $updatedAt;

   }



   public function __toString()

   {

       // TODO: Implement __toString() method.

       return $this->name;

   }



}

Timer Controller

Similar to managing projects, we will set up the controller for a timer

php bin/console make:controller TimerController

Locate this file and paste the code below:

<?php



namespace App\Controller;

use App\Entity\Timer;

use Doctrine\ORM\EntityManagerInterface;

use Symfony\Component\HttpFoundation\Request;

use Symfony\Component\HttpFoundation\Response;

use Symfony\Component\Routing\Annotation\Route;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;

use Symfony\Component\Serializer\Encoder\JsonEncoder;

use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;

use Symfony\Component\Serializer\Serializer;



class TimerController extends Controller

{

   /**

    * @var EntityManagerInterface

    */

   private $entityManager;



   /**

    * @var \Doctrine\Common\Persistence\ObjectRepository

    */

   private $projectRepository;



   /**

    * @var \Doctrine\Common\Persistence\ObjectRepository

    */

   private $timerRepository;




   public function __construct(EntityManagerInterface $entityManager)

   {

       $this->entityManager = $entityManager;

       $this->projectRepository = $entityManager->getRepository('App:Project');

       $this->timerRepository = $entityManager->getRepository('App:Timer');

   }



   /**

    * @Route("/projects/{id}/timers", name="timer")

    */

   public function createTimer(Request $request, int $id)

   {

       $content = json_decode($request->getContent(), true);



       $project = $this->projectRepository->find($id);



       $timer = new Timer();

       $timer->setName($content['name']);

       $timer->setUser($this->getUser());

       $timer->setProject($project);

       $timer->setStartedAt(new \DateTime());

       $timer->setCreated(new \DateTime());

       $timer->setUpdated(new \DateTime());

       $this->updateDatabase($timer);



       // Serialize object into Json format

       $jsonContent = $this->serializeObject($timer);



       return new Response($jsonContent, Response::HTTP_OK);



   }



   /**

    * @Route("/project/timers/active", name="active_timer")

    */

   public function runningTimer()

   {

       $timer = $this->timerRepository->findRunningTimer($this->getUser()->getId());



       $jsonContent = $this->serializeObject($timer);



       return new Response($jsonContent, Response::HTTP_OK);

   }



   /**

    * @Route("/projects/{id}/timers/stop", name="stop_running")

    */

   public function stopRunningTimer()

   {

       $timer = $this->timerRepository->findRunningTimer($this->getUser()->getId());



       if ($timer) {

           $timer->setStoppedAt(new \DateTime());

           $this->updateDatabase($timer);

       }



       // Serialize object into Json format

       $jsonContent = $this->serializeObject($timer);



       return new Response($jsonContent, Response::HTTP_OK);

   }



   public function serializeObject($object)

   {

       $encoders = new JsonEncoder();

       $normalizers = new ObjectNormalizer();



       $normalizers->setCircularReferenceHandler(function ($obj) {

          return $obj->getId();

       });

       $serializer = new Serializer(array($normalizers), array($encoders));



       $jsonContent = $serializer->serialize($object, 'json');



       return $jsonContent;

   }



   public function updateDatabase($object)

   {

       $this->entityManager->persist($object);

       $this->entityManager->flush();

   }

}

Firstly, we ensure that the timer created is for a particular project by passing the specific project’s ID into the controller. The next function fetches and returns the current active timers and lastly, a method to stop the actively running timer set up by the current user.

Timer Entity

Create a Timer object with :

php bin/console make:entity Timer

and update the content as shown:

<?php



namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;



/**

* @ORM\Entity(repositoryClass="App\Repository\TimerRepository")

*/

class Timer

{

   /**

    * @ORM\Id

    * @ORM\GeneratedValue

    * @ORM\Column(type="integer")

    */

   private $id;



   /**

    * @ORM\Column(type="string", name="name", length=100)

    */

   private $name;



   /**

    * @ORM\ManyToOne(targetEntity="App\Entity\Project", inversedBy="timers")

    * @ORM\JoinColumn(nullable=true)

    */

   private $project;



   /**

    * @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="timers")

    * @ORM\JoinColumn(nullable=true)

    */

   private $user;



   /**

    * @ORM\Column(type="datetime", name="started_at")

    */

   private $startedAt;



   /**

    * @ORM\Column(type="datetime", name="stopped_at", nullable=true)

    */

   private $stoppedAt;



   /**

    * @ORM\Column(type="datetime", name="created")

    */

   private $created;



   /**

    * @ORM\Column(type="datetime", name="updated")

    */

   private $updated;



   /**

    * @return mixed

    */

   public function getId()

   {

       return $this->id;

   }



   /**

    * @param mixed $id

    */

   public function setId($id)

   {

       $this->id = $id;

   }



   /**

    * @return mixed

    */

   public function getName()

   {

       return $this->name;

   }



   /**

    * @param mixed $name

    */

   public function setName($name)

   {

       $this->name = $name;

   }



   /**

    * @return mixed

    */

   public function getProject()

   {

       return $this->project;

   }



   /**

    * @param mixed $project

    */

   public function setProject($project)

   {

       $this->project = $project;

   }



   /**

    * @return mixed

    */

   public function getUser()

   {

       return $this->user;

   }



   /**

    * @param mixed $user

    */

   public function setUser($user)

   {

       $this->user = $user;

   }



   /**

    * @return mixed

    */

   public function getStartedAt()

   {

       return $this->startedAt;

   }



   /**

    * @param mixed $startedAt

    */

   public function setStartedAt($startedAt)

   {

       $this->startedAt = $startedAt;

   }



   /**

    * @return mixed

    */

   public function getStoppedAt()

   {

       return $this->stoppedAt;

   }



   /**

    * @param mixed $stoppedAt

    */

   public function setStoppedAt($stoppedAt)

   {

       $this->stoppedAt = $stoppedAt;

   }



   /**

    * @return mixed

    */

   public function getCreated()

   {

       return $this->created;

   }



   /**

    * @param mixed $created

    */

   public function setCreated($created)

   {

       $this->created = $created;

   }



   /**

    * @return mixed

    */

   public function getUpdated()

   {

       return $this->updated;

   }



   /**

    * @param mixed $updated

    */

   public function setUpdated($updated)

   {

       $this->updated = $updated;

   }

}

Timer Repository

For every entity generated, Symfony automatically creates a repository class and map it for you. This helps to isolate complex queries from the controller and creates a helper method that can easily be used in your project.

Open ./src/Repository/TimerRepository  and dump the code below:

`<?php



namespace App\Repository;



use App\Entity\Timer;

use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;

use Symfony\Bridge\Doctrine\RegistryInterface;



/**

* @method Timer|null find($id, $lockMode = null, $lockVersion = null)

* @method Timer|null findOneBy(array $criteria, array $orderBy = null)

* @method Timer[]    findAll()

* @method Timer[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)

*/

class TimerRepository extends ServiceEntityRepository

{

   public function __construct(RegistryInterface $registry)

   {

       parent::__construct($registry, Timer::class);

   }

   public function findRunningTimer($user)

   {

       return $this->createQueryBuilder('t')

           ->where('t.user = :user')

           ->andWhere('t.stoppedAt is NULL')

           ->setParameter('user', $user)

           ->getQuery()

           ->getOneOrNullResult();

   }

}

This makes it very easy for us to call findRunningTimer  and get all the active timer for a particular project within the TimerController.

Create Database Table/Schema and Run Migration

Now that we are done setting up the required entity and controller. The next line of action is to create the database specified earlier and run migrations. We already have DoctrineMigrationsBundle installed already, lets leverage on that.

Run this command to create the database:

php bin/console doctrine:database:create

Note: don’t forget to add the database credentials in your .env file as stated earlier.

To finally add a table to the database run :

php bin/console doctrine:migrations:diff

If there are no errors, you should see a success message in the terminal indicating that a new migration class.

Finally, update your database with the SQL generated:

php bin/console doctrine:migrations:migrate

Setting Up The Frontend

As pointed out here in this article Symfony is not an opinionated framework when it comes to making a choice of JavaScript framework or library that powers the client side logic. Vue.js is the choice for this tutorial but before we set it up, let’s have a quick look at the directories within templates. You should see something similar to this.

These directories were automatically generated for each of the controllers. Let’s update the contents of the templates files. Needless to say that we only need to focus on just four of all these directories and that includes Default, Home, registration and security.

Templates in our project share common elements and since Symfony supports template inheritance, we will need to update the base layout template first.

Open ./templates/base.html.twig  and replace the content with :

<!DOCTYPE html>

<html>

<head>

   <meta charset="UTF-8">

   <title>{% block title %} Time Tracker Application{% endblock %}</title>

   {% block stylesheets %}

       <link rel="stylesheet" href="{{ asset('build/css/app.css') }}">

   {% endblock %}

</head>

<body>



<div id="app">

   <nav class="navbar navbar-default">

       <div class="container-fluid">

           <div class="navbar-header">

               <a class="navbar-brand" href="{{ path('default') }}"> Time Tracker Application </a>

           </div>

           <ul class="nav navbar-nav navbar-right">

               {% if app.user %}

                   <li><a href="{{ path('home') }}">Project</a></li>

                   <li><a href="{{ path('logout') }}">Logout</a></li>



               {% else %}



                   <li><a href="{{ path('login') }}">Login</a></li>

                   <li><a href="{{ path('user_registration') }}">Register</a></li>



               {% endif %}

           </ul>

       </div>

   </nav>




   {% block body %}{% endblock %}

</div>



{% block javascripts %}

   <script src="{{ asset('build/js/app.js') }}"></script>

{% endblock %}

</body>

</html>

We set the title for our application and added both CSS and JavaScript asset. Noticed the body block? That’s where the body of other templates that will inherit this layout will be rendered

Next, open the Default directory and use the content below for the index.html.twig file.

{% extends 'base.html.twig' %}



{% block title %}

   Time Tracker Application

{% endblock %}



{% block body %}



   <div class="example-wrapper">

       <div class="jumbotron text-center">

           <h2> Projects Time Tracker built with Symfony and Vue </h2>



           <div>

               {% if app.user %}



                   <a href="{{ path('home') }}" class="btn btn-default">Project</a>



               {% else %}

                   <a href="{{ path('login') }}" class="btn btn-default">Login</a>

                   <a href="{{ path('user_registration') }}" class="btn btn-default">Register</a>



               {% endif %}

           </div>

       </div>

   </div>

{% endblock %}

Registration

This file houses the form for registering the details of users within our application. It can be found in here ./templates/registration/index.html.twig. Change the name of the file to register.html.twig. This is not mandatory we just need to be consistent considering that we already have this files in our controller already. Once you are done, update the content like so :

{% extends 'base.html.twig' %}

{% block title %} Register {% endblock %}

{% block body %}



   <div class="col-sm-12 wrapper">

       <div class="auth-form">

           <h2 class="text-center">Register</h2>

           <hr>

           {{ form_start(form) }}

           <div class="col-md-12">

               <div class="form-group col-md-12">

                   {{ form_row(form.username) }}

               </div>

               <div class="form-group col-md-12">

                   {{ form_row(form.email) }}

               </div>

               <div class="form-group col-md-12">

                   {{ form_row(form.plainPassword.first) }}

               </div>

               <div class="form-group col-md-12">

                   {{ form_row(form.plainPassword.second) }}

               </div>



           </div>

           <div class="form-group col-md-12">

               {{ form_row(form.submit) }}

           </div>

           {{ form_end(form) }}



           <div class="text-center">

               Have an account <span><a href="{{ path('login') }}">Login</a></span>

           </div>

       </div>

   </div>



{% endblock %}

Login

To include the form for logging in users open ./templates/security directory and change the name of the file to login.html.twig, then change the content

{% extends 'base.html.twig' %}

{% block title %}Login {% endblock %}

{% block body %}

   <div class="col-sm-12 wrapper">

       <div class="auth-form">

           <h2 class="text-center">Login</h2>

           <hr>

           {% if error %}

               <div class="alert alert-danger alert-dismissible">

                   <a href="#" class="close" data-dismiss="alert" aria-label="close">&times;</a>

                   <strong>Error!</strong> {{ error.messageKey|trans(error.messageData, 'security') }}

               </div>

           {% endif %}

           <form action="{{ path('login') }}" method="post">

               <div class="form-group col-md-12">

                   <label for="username">Username:</label>

                   <input type="text" id="username" class="form-control" name="_username" value="" autocomplete="off"/>

               </div>

               <div class="form-group col-md-12">

                   <label for="password">Password:</label>

                   <input type="password" id="password" class="form-control" name="_password" />

               </div>

               <div class="form-group">

                   <button type="submit" class="btn btn-success form-control">Login</button>

               </div>

               <div class="text-center form-group">

                   Don't have an account? <span><a href="{{ path('user_registration') }}">Register</a></span>

               </div>

           </form>

       </div>

   </div>

{% endblock %}

Finally, all users will be redirected to a homepage once registration is successful and can proceed to add projects and timers.

Within ./templates/home directory, update the template file with the name home.html.twig and this content:

{% extends 'base.html.twig' %}

{% block title %}Symfony Time Tracker {% endblock %}

{% block body %}

   <div class="container">

       <div class="row">

           <home></home>

       </div>

   </div>



{% endblock %}

This looks simple enough, noticed the <home></home> element? That is a Vue component called home. We will create this in a bit.

Test the Application

Start the server if not already running :

php bin/console server:run

Pretty ugly right? Let’s change this

Install Vue and Its Dependencies

To manage assets, we already have Encore installed so we are guaranteed a proper asset management with minimal effort of configuration. Before proceeding, ensure that you have Node.js and Yarn package manager installed on your machine.

yarn add --dev vue vue-loader vue-template-compiler

Install bootstrap and jQuery

yarn add sass-loader node-sass jquery bootstrap-sass --dev

other packages include Moment and Axios

yarn add moment axios

Once completed, within the project directory, open webpack.config.js and configure Vue-loader and set up the entry point for both JavaScript and Stylesheets. Open it and replace the contents with :

let Encore = require('@symfony/webpack-encore');

Encore

// the project directory where compiled assets will be stored

   .setOutputPath('public/build/')

   // the public path used by the web server to access the previous directory

   .setPublicPath('/build')

   .cleanupOutputBeforeBuild()

   .enableSourceMaps(!Encore.isProduction())

   

   // uncomment to define the assets of the project

   .addEntry('js/app', './assets/js/app.js')

   .addStyleEntry('css/app', './assets/css/app.scss')



   // uncomment if you use Sass/SCSS files

   .enableSassLoader()



   // Enable Vue Loader

   .enableVueLoader()

;

module.exports = Encore.getWebpackConfig();

Webpack encore configuration expects two separate entry points. So, create two subdirectories: js and css within assets directory. Then proceed to create app.js in the js directory and app.scss in css directory. By now, we will have the following directory structure

  • ./assets/css/app.scss
    ./assets/js/app.js
    // assets/css/app.scss
    
    @import '~bootstrap-sass/assets/stylesheets/bootstrap';
    
    .wrapper {
    
     margin-top: 30px;
    
    }
    
    .auth-form {
    
     width: 450px;
    
     margin: 0 auto;
    
    }
    
    

Here, I have only imported Bootstrap and added minimal styles for the application.

// assets/js/app.js

global.jQuery = require('jquery');

global.axios = require('axios');

require('bootstrap-sass');

import Vue from 'vue';

import Home from './components/HomeComponent'

new Vue({

   el: '#app',

   components: {Home}

});

In the above code snippet, I made jQuery and Axios globals be used anywhere in the application, I then proceeded to require bootstrap. Next, I imported Vue and Home component and then created an instance of Vue, pass an element where Vue will attach itself to and register the Home component so that it can be accessible.

Create Home Component

Earlier we used a custom directive within the ./templates/home/home.html.twig file. We call it the HomeComponent. Lets create this component. For this, create a new directory assets/js/components and a new component file named HomeComponent.vue within it.

Home Component

Components in Vue.js consists of three parts structurally. They are :

  • <template></template>
  • <script></script>
  • <style></style>

We won’t need to use the style part of this component, but first, let’s update the contents of the template :

// assets/js/components/HomeComponent.vue

<template>

   <div class="col-md-8 col-md-offset-2">

       <div class="no-projects" v-if="projects">



           <div class="row">

               <div class="col-sm-12">

                   <h2 class="pull-left project-title">Projects</h2>

                   <button class="btn btn-default btn-sm pull-right" data-toggle="modal" data-target="#projectCreate">New Project</button>

               </div>

           </div>



           <hr>



           <div v-if="projects.length > 0">

               <div class="panel panel-default" v-for="project in projects" :key="project.id">

                   <div class="panel-heading clearfix">

                       <h4 class="pull-left">{{ project.name }}</h4>



                       <button class="btn btn-success btn-sm pull-right" :disabled="counter.timer" data-toggle="modal" data-target="#timerCreate" @click="selectedProject = project">

                           <i class="glyphicon glyphicon-plus"></i>

                       </button>

                   </div>



                   <div class="panel-body">

                       <ul class="list-group" v-if="project.timers.length > 0">

                           <li v-for="timer in project.timers" :key="timer.id" class="list-group-item clearfix">

                               <strong class="timer-name">{{ timer.name }}</strong>

                               <div class="pull-right">

                                       <span v-if="showProjectTimer(project, timer)" style="margin-right: 10px">

                                           <strong>{{ activeTimerString }}</strong>

                                       </span>

                                   <span v-else>

                                           <strong>{{ calculateTotalTimeSpent(timer) }}</strong>

                                       </span>

                                   <button v-if="showProjectTimer(project, timer)" class="btn btn-sm btn-danger" @click="stopActiveTimer()">

                                       <i class="glyphicon glyphicon-stop"></i>

                                   </button>

                               </div>

                           </li>

                       </ul>

                       <p v-else>

                           No task has been recorded for <b>"{{ project.name }}"</b> yet.

                           Click the plus icon to add task and then, the play icon to start recording.

                       </p>

                   </div>

               </div>

               <!-- Create Timer Modal -->

               <div class="modal fade" id="timerCreate" role="dialog">

                   <div class="modal-dialog modal-sm">

                       <div class="modal-content">

                           <div class="modal-header">

                               <button type="button" class="close" data-dismiss="modal">×</button>

                               <h4 class="modal-title">Record Time</h4>

                           </div>

                           <div class="modal-body">

                               <div class="form-group">

                                   <input v-model="newTimerName" type="text" class="form-control" id="username" placeholder="What are you working on?">

                               </div>

                           </div>

                           <div class="modal-footer">

                               <button data-dismiss="modal" v-bind:disabled="newTimerName === ''" @click="createTaskTimer(selectedProject)" type="submit" class="btn btn-default btn-success"><i class="glyphicon glyphicon-play"></i> Start</button>

                           </div>

                       </div>

                   </div>

               </div>

           </div>

           <div v-else>

               <h3 align="center">You need to create a new project</h3>

           </div>

           <!-- Create Project Modal -->

           <div class="modal fade" id="projectCreate" role="dialog">

               <div class="modal-dialog modal-sm">

                   <div class="modal-content">

                       <div class="modal-header">

                           <button type="button" class="close" data-dismiss="modal">×</button>

                           <h4 class="modal-title">New Project</h4>

                       </div>

                       <div class="modal-body">

                           <div class="form-group">

                               <input v-model="newProjectName" type="text" class="form-control" id="project-name" placeholder="Project Name">

                           </div>

                       </div>

                       <div class="modal-footer">

                           <button data-dismiss="modal" v-bind:disabled="newProjectName == ''" @click="createNewProject" type="submit" class="btn btn-default btn-success">Create</button>

                       </div>

                   </div>

               </div>

           </div>

       </div>

       <div class="timers" v-else>

           Loading...

       </div>

   </div>

</template>

A few notes:

In the code above, once a project is created,

  • We basically loop through each of the timers and renders it to the view.
  • Included few methods to check if projects have been created,
  • Show timer for the created project
  • And then calculate time spent.

These methods will be declared within the script section of the component.

Script

In the same file, update the script section with :

<script>

   import moment from 'moment'

   export default {

       data: function() {

           return {

               projects: null,

               newTimerName: '',

               newProjectName: '',

               activeTimerString: 'Calculating...',

               counter: { seconds: 0, timer: null },

           }

       },

       created() {

           axios.get('/projects').then(res => {

               this.projects = res.data;

               axios.get('/project/timers/active').then(res => {

                   if (res.data.id !== undefined) {

                       this.startCountingTimer(res.data.project, res.data)

                   }

               })

           })

       },

       methods: {

           /**

            * Conditionally pads a number with "0"

            */

           _padNumber: number =>  (number > 9 || number === 0) ? number : "0" + number,



           /**

            * created a readable time by splitting seconds into a proper format

            * @param(seconds)

            */

           _readableTimeFromSeconds: function(seconds) {

               const hours = 3600 > seconds ? 0 : parseInt(seconds / 3600, 10)

               return {

                   hours: this._padNumber(hours),

                   seconds: this._padNumber(seconds % 60),

                   minutes: this._padNumber(parseInt(seconds / 60, 10) % 60),

               }

           },



           /**

            * The amount of time spent while working on a task.

            * @param(timer)

            */

           calculateTotalTimeSpent(timer) {

               if (timer.stoppedAt) {

                   const started = moment(new Date(timer.startedAt.timestamp * 1000), "YYYY/MM/DD HH:mm:ss");

                   const stopped = moment(new Date(timer.stoppedAt.timestamp * 1000), "YYYY/MM/DD HH:mm:ss");

                   const time = this._readableTimeFromSeconds(

                       parseInt(moment.duration(stopped.diff(started)).asSeconds())

                   );

                   return `${time.hours} Hours | ${time.minutes} mins | ${time.seconds} seconds`

               }

               return ''

           },



           /**

            * Check for active timers for a particular created project.

            * @param(project,timer)

            */

           showProjectTimer(project, timer) {

               return this.counter.timer &&

                   this.counter.timer.id === timer.id &&

                   this.counter.timer.project.id === project.id

           },



           /**

            * Activate timer for a task.

            */

           startCountingTimer(project, timer) {

               const started = moment(timer.startedAt)



               this.counter.timer = timer;

               this.counter.timer.project = project;

               this.counter.seconds = parseInt(moment.duration(moment().diff(started)).asSeconds());

               this.counter.ticker = setInterval(() => {

                   const time = this._readableTimeFromSeconds(++this.counter.seconds);



                   this.activeTimerString = `${time.hours} Hours | ${time.minutes}:${time.seconds}`

               }, 1000)

           },



           /**

            * Stop the timer for a particular task.

            */

           stopActiveTimer() {

               axios.post(`/projects/${this.counter.timer.id}/timers/stop`)

                   .then(res => {

                       // Loop through and get the right project...

                       this.projects.forEach(project => {

                           if (project.id === parseInt(this.counter.timer.project.id)) {

                               return project.timers.forEach(timer => {

                                   if (timer.id === parseInt(this.counter.timer.id)) {

                                       return timer.stoppedAt = res.data.stoppedAt

                                   }

                               })

                           }

                       });



                       clearInterval(this.counter.ticker);

                       // Reset the counter and timer string

                       this.counter = { seconds: 0, timer: null }

                       this.activeTimerString = 'Calculating...'

                   })

           },



           /**

            * Create a new timer for a particular task.

            */

           createTaskTimer(project) {

               axios.post(`/projects/${project.id}/timers`, {name: this.newTimerName})

                   .then(res => {

                       project.timers.push(res.data);

                       this.startCountingTimer(res.data.project, res.data)

                   });



               this.newTimerName = ''

           },



           /**

            * Create a new project.

            */

           createNewProject() {

               axios.post('/projects/create', {name: this.newProjectName})

                   .then(res => {

                       this.projects.push(res.data)

                   })

           }

       },

   }

</script>

This might look daunting, but let’s try to break it down.

Firstly, we imported moment in order to properly calculate the time difference and then proceeded to declare variables that will be used later on within the file.

We implemented one of the lifecycle hooks used in Vue.js called created.

created() {

   axios.get('/projects').then(res => {

       this.projects = res.data;

       axios.get('/project/timers/active').then(res => {

           if (res.data.id !== undefined) {

               this.startCountingTimer(res.data.project, res.data)

           }

       })

   })

},

Within it, we make a request to the backend using Axios to GET all the created projects and assign it to the project’s variable. Furthermore, we check for if the project is active and then start the timer.

In addition, we also have stopActiveTimer, createTaskTimer, and createNewProject methods. These methods basically carry out the functionality of making a request to the backend in order to start a timer for a new task, create a timer for a task and create a new project respectively.

Run the Application

We have already included the HomeComponent as a custom tag <home></home> within ./templates/home/home.html.twig.

Run the command below to compile both JavaScript and SCSS file.

yarn run encore dev --watch

Now restart the server if not already running with :

php bin/console server:run

You should be greeted with a page like this

And once you are logged in :

WRAPPING UP

This project has not only provided an opportunity to build a mini-application that helps to improve productivity but also offers us the opportunity to explore the awesome combination of Symfony and Vue.js.

Hopefully, the knowledge gained here can be used to enhance other project built with Symfony.

I hope you found this tutorial helpful and if you would like to get the code and improve on it by adding extra features, you can find it here on GitHub.

Lastly, If you encountered any issues while building this, kindly leave a comment below.

Supercharged Managed PHP Hosting – Improve Your PHP App Speed by 300%

Author of the Post: This post is contributed by Olususi Oluyemi. He is a tech enthusiast, programming freak and a web development junkie who loves to embrace new technology. You can find him on yemiwebby.

Share your opinion in the comment section. COMMENT NOW

Share This Article

Olususi k Oluyemi

A tech enthusiast, programming freak and a web development junkie who loves to embrace new technology.

×

Get Our Newsletter
Be the first to get the latest updates and tutorials.

Thankyou for Subscribing Us!

×

Webinar: How to Get 100% Scores on Core Web Vitals

Join Joe Williams & Aleksandar Savkovic on 29th of March, 2021.

Do you like what you read?

Get the Latest Updates

Share Your Feedback

Please insert Content

Thank you for your feedback!

Do you like what you read?

Get the Latest Updates

Share Your Feedback

Please insert Content

Thank you for your feedback!

Want to Experience the Cloudways Platform in Its Full Glory?

Take a FREE guided tour of Cloudways and see for yourself how easily you can manage your server & apps on the leading cloud-hosting platform.

Start my tour

CYBER WEEK SAVINGS

  • 0

    Days

  • 0

    Hours

  • 0

    Mints

  • 0

    Sec

GET OFFER

For 4 Months &
40 Free Migrations

For 4 Months &
40 Free Migrations

Upgrade Now