Chapter 3. TAKING THE PAIN OUT OF WORKING WITH DATES

TAKING THE PAIN OUT OF WORKING WITH DATES

It's a well-known fact that most of the formatting characters used by date() and strftime() seem to bear no logical relationship with the values they represent. Even the formatting characters, m (month) and d (date), used with date() aren't completely straightforward. Do they output a leading zero or not? (They do.) But if you don't want the leading zero, can you remember the right characters to use? OK, I'll put you out of your misery; it's n for month and j for date.

Wouldn't it be great if you could format dates in PHP without the need to look up the formatting characters in a book or the online manual every time? Well, that's what this chapter is about. A little-known fact is that a new DateTime class was added in PHP 5.2 (an experimental version can be enabled in PHP 5.1—see http://docs.php.net/manual/en/datetime.installation.php). For some unexplained reason, this class has a rather limited set of methods; and the DateTime::format() method uses exactly the same formatting characters as date(). However, one of the great principles of OOP is encapsulation—hiding the details from the end user—so you can create your own class to handle and format dates in a more user-friendly way.

In this chapter, you will

  • Use the PHP Reflection API to inspect class methods and properties

  • Use inheritance to extend the built-in PHP DateTime class

  • Override the DateTime parent constructor to check the validity of the date

  • Use encapsulation to hide the standard date formatting characters

  • Create a static method to calculate the number of days between two dates

  • Build a series of methods to perform date-related calculations

If this is your first experience of working with OOP in PHP, you'll see very quickly that much of the code inside each method of a custom class is exactly the same as you would use in procedural coding. The only real difference is that, instead of typing little snippets of code to do a specific job in a single project, you're building a generic set of functions that will come in useful across a wide range of projects. Although each section of code is quite short, the finished class definition is several hundred lines long.

Before getting down to the actual code, it's always a good idea to set out the objectives of the new class.

Designing the class

OOP is all about code reuse, so the first step in designing a class should be to consider what's already available within core PHP and how it might be recycled or improved. A quick check of the Date and Time Functions page in the PHP Manual (http://docs.php.net/manual/en/function.date-format.php) shows that nearly 40 functions are available (as of PHP 5.2). What might not be so obvious at first glance is that many of them are the procedural equivalents of two classes that were added to core PHP in version 5.2: DateTime and DateTimeZone.

It's worth taking a closer look at these classes to find out what methods they offer and whether they can be extended or overridden.

Examining the built-in date-related classes

At the time of this writing, the PHP Manual doesn't describe the DateTime and DateTimeZone classes. Instead, you need to look up the equivalent procedural function for each method. A quick way of inspecting the classes yourself is to use the Reflection application programming interface (API) introduced in PHP 5. Reflection is the process of examining the inner workings of functions, classes, and objects. The Reflection API comprises a set of specialized classes that can be used to extract information about a class. All the methods have descriptive names, so it's quite easy to use once you're familiar with OOP. You can find more details about the Reflection API, along with examples of its use at http://docs.php.net/manual/en/language.oop5.reflection.php. For the purposes of this chapter, I'm going to use the Reflection API in the simplest possible way by using the static Reflection::export() method to examine the DateTime and DateTimeZone classes.

To examine a class, you pass the name of the class you're interested in to the ReflectionClass constructor. Since the DateTime and DateTimeZone classes are part of core PHP, you can use them directly in a script without needing to include any external class files. The following code produces the output shown in Figure 3-1 (the code is in inspect_DateTime.php in the ch3_exercises folder; I have wrapped it in <pre> tags to make the display easier to read):

Reflection::export(new ReflectionClass('DateTime'));
The Reflection API exposes a lot of detail about the methods and properties of a class.

Figure 3.1. The Reflection API exposes a lot of detail about the methods and properties of a class.

At a glance, this tells you that the DateTime class has 11 constants, no static properties or methods, no properties, and nine methods, all of which are public. This means that everything can be inherited and, if necessary, overridden. I'll come back to the constants later. Let's take a look at the methods. Table 3-1 lists each method, the arguments it takes, and a description of its use.

Table 3.1. Methods of the PHP DateTime class

Method

Arguments

Description

__construct()

$date, $timezone

$date is a string in a format accepted by strtotime(). $timezone is a DateTimeZone object. Both arguments are optional. If no arguments are passed to the constructor, it creates a DateTime object representing the current date and time for the default time zone.

format()

$dateFormat

$dateFormat is a string consisting of the same date formatting characters accepted by the procedural date() function. The DateTime::format() method is simply an object-oriented version of date().

modify()

$relativeDate

$relativeDate is a string in a format accepted by strtotime(), e.g. '+1 week'. It modifies the date and time stored by the object by the duration specified.

getTimezone()

 

This takes no arguments. It returns a DateTimeZone object representing the time zone stored by the DateTime object, or false on failure.

setTimezone()

$timezone

$timezone must be a DateTimeZone object. This sets the time zone stored by the DateTime object, and returns null on success, or false on failure.

getOffset()

 

This takes no arguments. This returns the offset from Universal Coordinated Time (UTC, also known as GMT) in seconds on success, or false on failure.

setTime()

$hour, $minute, $second

This resets the time stored by the object. The arguments should be a comma-separated list of integers. The third argument is optional.

setDate()

$year, $month, $date

This resets the date stored by the object. The arguments should be a comma-separated list of integers and must be in the specified order of year, month, date. This is different from the procedural function mktime(), which follows the American convention of month, date, year.

setISODate()

$year, $week, $dayOfWeek

This is a specialized way of representing the date using the "week date" format of ISO 8601, a standard laid down by the International Organization for Standardization (ISO). The arguments should be a comma-separated list of integers, as follows: $year is the calendar year, $week is the ISO 8601 week number, and $dayOfWeek is a number from 1 (Monday) to 7 (Sunday). So, to set a DateTime object to August 8, 2008, in this way, you need to pass the arguments like this: $olympics->setISODate(2008, 32, 5);.

Using the DateTime class

The way you use the DateTime class is like any other: instantiate an object, and store it in a variable like this:

$date = new DateTime();

You can then apply any of the methods listed in Table 3-1 (apart from __construct(), which is called automatically when you instantiate an object) by using the -> operator. To format the date, use the format() method with the standard PHP date formatting characters like this (the code is in date_test_01.php in the ch3_exercises folder):

$date = new DateTime();
echo $date->format('l, F jS, Y'),

This creates a DateTime object representing the current date and time and displays it as shown in Figure 3-2.

DateTime::format() displays the date in the same way as the standard date() function.

Figure 3.2. DateTime::format() displays the date in the same way as the standard date() function.

If you're familiar with the date() function that's been around since PHP 4, you're probably wondering what's the point of using a DateTime object. After all, you can produce exactly the same output as in Figure 3-2 with the following code:

echo date('l, F jS, Y'),

It's much shorter and does exactly the same thing. Moreover, you need to use the same formatting characters, even if you choose the object-oriented approach.

Note

Sometimes the procedural approach in PHP is quicker and simpler to implement. If it makes sense to use procedural code in a particular situation, don't feel obliged to go the object-oriented route because it's "more advanced." Choose the right solution for the job in hand.

The DateTime class is more useful when you're working with dates other than the current date and time. Rather than juggle several different functions, such as mktime(), strtotime(), strftime(), and date(), everything is handled by the object and its methods. For example, you can display next Thursday's date by changing the previous code like this (it's in date_test_02.php):

$date = new DateTime('next Thursday');
echo $date->format('l, F jS, Y'),

This produces the output shown in Figure 3-3 (obviously, the actual date will depend on when you run the script).

The DateTime class makes it easy to create dates based on natural language.

Figure 3.3. The DateTime class makes it easy to create dates based on natural language.

You can also use any of the eleven constants defined by the class to format a DateTime object. Because they are class constants, you need to prefix them with the class name and the scope resolution operator like this:

echo $date->format(DateTime::ATOM);

Figure 3-4 shows the output of each constant as applied to the same DateTime object. You can test the code yourself in date_test_03.php. Not only will the date and time be different, depending on when you view it, but the time zone offset will also change if your server is in a different part of the world.

The same DateTime object as formatted by each of the class constants

Figure 3.4. The same DateTime object as formatted by each of the class constants

Note

Because older date and time functions are not part of the DateTime class, global equivalents of these constants also exist. Just prefix the constant with DATE_ instead of the class name and scope resolution operator, for example, DATE_ATOM instead of DateTime::ATOM. You can use either version with the DateTime class. Use the global constants with other date and time functions.

To change the date stored by a DateTime object after it has been created, you use DateTime::modify() with a natural language expression, or DateTime::setDate() with an actual date like this:

$date->modify('+2 months'),  // adds two months to existing date
$date->setDate(2008, 8, 8);  // sets date to August 8, 2008

An important feature of the object-oriented way of handling dates in PHP is the ability to set the time zone of a DateTime object. You can set the time zone explicitly by passing a DateTimeZone object as the second argument to the constructor. I'll explain how to do this in "Using the DateTimeZone class" a little later in the chapter. However, this usually isn't necessary, as DateTime objects use the default time zone for your server.

Setting the default time zone in PHP

As its name suggests, the World Wide Web is international, and one of the biggest frustrations of handling dates in PHP scripts arises when your server is in a different time zone from your target audience. Since PHP 5.1, this is no longer a problem. The server administrator should set the default time zone in php.ini, using the date.timezone directive. You can check the value on your server by running phpinfo() and scrolling down to the date section. As you can see from Figure 3-5, Default timezone on my server is set to Europe/London.

It's a good idea to check the time zone being used by your server.

Figure 3.5. It's a good idea to check the time zone being used by your server.

There are far too many time zones to list here, but you can find a list of all time zones supported by PHP at http://docs.php.net/manual/en/timezones.php. If the default time zone on your server doesn't suit your needs, never fear. There are several ways to reset it. If the server is under your own control, the best way to do it is to change the value of date.timezone in php.ini, and restart your web server. If you're on shared hosting and don't have access to php.ini, you can choose from one of the following methods (replace Europe/London in the following examples with the appropriate time zone from among those listed at the previous URL).

Resetting the time zone in .htaccess

If your server runs on Apache, and your hosting company has set the correct permissions for you to use an .htaccess file, add the following command to the .htaccess file in your site root (see http://en.wikipedia.org/wiki/Htaccess if you're not familiar with .htaccess files):

php_value date.timezone 'Europe/London'

This changes the default time zone for every page on the site.

Resetting the time zone in individual scripts

If you can't change the configuration for the whole site, or need to change the time zone only for a specific script, add the following line of code before using any date functions:

ini_set('date.timezone', 'Europe/London'),

Alternatively, use this:

date_default_timezone_set('Europe/London'),

Both do exactly the same. It doesn't matter which you use. It's a good idea to put configuration changes like this at the top of the script so that they are immediately obvious and available.

Examining the DateTimeZone class

The DateTimeZone class is a close companion of the DateTime class, and it gives you much greater control over the use of time zones, allowing you to create objects to represent times in different parts of the world, regardless of the default time zone for your server. Using the Reflection API to examine the DateTimeZone class with the following code (it's also in inspect_DateTimeZone.php) produces the output shown in Figure 3-6:

Reflection::export(new ReflectionClass('DateTimeZone'));
The details of the DateTimeZone class exposed by the Reflection API

Figure 3.6. The details of the DateTimeZone class exposed by the Reflection API

As you can see from the output of the Reflection API, the DateTimeZone class has no constants or properties. It also has only six methods, but two of them are static. Table 3-2 describes each method.

Table 3.2. Methods of the PHP DateTimeZone class

Type

Method

Arguments

Description

Static

listAbbreviations()

 

This produces a huge multidimensional array listing the following elements for each time zone: daylight saving time (dst), offset from UTC in seconds (offset), and the name by which it is identified in PHP (timezone_id). The subarrays are grouped according to international time zone identifiers in lowercase characters. You can see the output in date_test_04.php.

 

listIdentifiers()

 

This produces an array of all PHP time zone identifiers (there are more than 550) in alphabetical order. You can see the output in date_test_05.php.

Nonstatic

__construct()

$identifier

This creates a DateTimeZone object. $identifier must be a string consisting of one of the PHP time zone identifiers. You can find the supported identifiers by running the static function listed earlier or by going to http://docs.php.net/manual/en/timezones.php.

 

getName()

 

This returns the name of the time zone represented by a DateTimeZone object on success or false on failure.

 

getOffset()

$dateTime

This returns the offset from UTC in seconds of $dateTime, which must be a DateTime object. The calculation is based on the time zone stored in the DateTimeZone object, rather than that stored in $dateTime. One use is to calculate the time difference between two locations. For an example, study the code in date_test_06.php.

 

getTransitions()

 

This outputs a multidimensional array listing past and future changes to the offset from UTC for a DateTimeZone object. This enables you to calculate whether daylight saving time is in force at a specific date and time. Each subarray contains the following elements: the Unix timestamp for the time of the transition to or from daylight saving time (ts), the date and time in ISO 8601 format (time), the offset from UTC in seconds (offset), whether daylight saving time is in force (isdst), and the official time zone abbreviation (abbr). Figure 3-7 shows part of the output for America/New_York (the code is in date_test_07.php).

DateTimeZone::getTransitions() provides historical and future data about daylight saving time for all time zones.

Figure 3.7. DateTimeZone::getTransitions() provides historical and future data about daylight saving time for all time zones.

Using the DateTimeZone class

A quick glance at Table 3-2 reveals that most methods of the DateTimeZone class are rather specialized, so I don't intend to dwell on them at length. Use the files listed in Table 3-2 to see how they work. Take particular note of how DateTimeZone::getOffset() works in date_test_06.php. It uses the current time in New York to work out the time difference between New York and Los Angeles. The code is fully commented to explain how it works, and you can experiment by changing the time zone identifiers at the beginning of the script.

The most common use of the DateTimeZone class is to alter the time zone of a DateTime object. First, you create a DateTimeZone object by passing the time zone identifier to the constructor. Then you pass the DateTimeZone object as the second argument to the DateTime constructor like this (the code is in date_test_08.php):

$Tokyo = new DateTimeZone('Asia/Tokyo'),
$now = new DateTime('now', $Tokyo);
echo "<p>In Tokyo, it's " . $now->format('g:i A') . '</p>';

This displays the current date and time in Tokyo, regardless of the time zone on your own server.

You can also use a DateTimeZone object to change the time zone of an existing DateTime object by passing it as an argument to DateTime::setTimezone(). The following code (it's also in date_test_09.php) displays the current time in different parts of the world by resetting the time zone of the DateTime object stored as $now:

// create a DateTime object
$now = new DateTime();
echo '<p>My local time is ' . $now->format('l, g:i A') . '</p>';

// create DateTimeZone objects for various places
$Katmandu = new DateTimeZone('Asia/Katmandu'),
$Moscow = new DateTimeZone('Europe/Moscow'),
$Timbuktu = new DateTimeZone('Africa/Timbuktu'),
$Chicago = new DateTimeZone('America/Chicago'),
$Fiji = new DateTimeZone('Pacific/Fiji'),

// reset the time zone for the DateTime object to each time zone in turn
$now->setTimezone($Katmandu);
echo "<p>In Katmandu, it's " . $now->format('l, g:i A') . '</p>';
$now->setTimezone($Moscow);
echo "<p>In Moscow, it's " . $now->format('l, g:i A') . '</p>';
$now->setTimezone($Timbuktu);
echo "<p>In Timbuktu, it's " . $now->format('l, g:i A') . '</p>';
$now->setTimezone($Chicago);
echo "<p>In Chicago, it's " . $now->format('l, g:i A') . '</p>';
$now->setTimezone($Fiji);
echo "<p>And in Fiji, it's " . $now->format('l, g:i A') . '</p>';

This script produces output similar to Figure 3-8.

The DateTimeZone class makes it easy to display the time in different parts of the world.

Figure 3.8. The DateTimeZone class makes it easy to display the time in different parts of the world.

This example uses the current time, but you can specify the date and time of a DateTime object, so using the DateTime and DateTimeZone classes in combination with each other makes it very easy to display the time of live events on a web site, showing the local time in various parts of the world where your target audience is likely to be. Remember, though, that if you need to maintain a reference to separate time zones in different parts of a script, it's better to create a DateTime object for each one like this:

$myTime = new DateTime();
$westCoast = new DateTimeZone('America/Los_Angeles'),
$LAtime = new DateTime('now', $westCoast);

Now that you have got a good idea of the DateTime and DateTimeZone classes, you can set about deciding how to design a custom class that builds on the existing functionality.

Deciding how to extend the existing classes

That overview of the DateTime and DateTimeZone classes was deliberately detailed, as there's no point reinventing the wheel. The first decision is easy: the DateTimeZone class does all the work related to time zones. Unless you plan to do complex time zone calculations, it doesn't need extending. As long as the extended DateTime class doesn't override the DateTime::setTimezone() or DateTime::getTimezone() methods, everything to do with time zones will be taken care of by the existing classes.

That leaves the following methods of the DateTime class that you need to decide what to do with, if anything:

  • __construct()

  • format()

  • modify()

  • getOffset()

  • setTime()

  • setDate()

  • setISODate()

It's easy to decide about getOffset() and setISODate(). They perform specialized tasks and don't need to be changed. All the others, however, present problems.

Although setTime() and setDate() accept arguments in a logical order, they accept out-of-range values. For instance, the minutes argument of setTime() quite happily accepts a value of 75 and silently converts it to 1 hour 15 minutes. Similarly, setDate() sets September 31 to October 1. Sometimes, that might be what you want, but changing values silently like this can lead to unexpected results. A date and time object that represents the wrong date or time is worthless.

The constructor and modify() methods suffer from the same drawback, as they both emulate strtotime(). Although strtotime() is extremely useful, it silently converts invalid dates to what PHP thinks you meant. For instance, it accepts February 29, 2007, without complaint, but the resulting timestamp is for March 1, 2007 (because 2007 wasn't a leap year). For this reason, I have decided to allow the constructor to instantiate objects only for the current date and time, although it will allow you to specify a time zone. This means you will need to go through two extra steps to reset the date and time, but I think this is a worthwhile tradeoff for accuracy. To prevent modify() from being used, I've gone for a more draconian solution: throw an exception.

That leaves just format(). The problem with this method is that it relies on the same mind-numbing formatting characters as date(). However, instead of trying to remember that F is used to format the name of the month, you can encapsulate the formatting process inside a method called getMonthName(). Because format() is a public method, it will remain accessible to objects created by the new class, should you still want to use it.

So, the only methods that will be inherited without any changes are format(), getOffset(), and setISODate(). The others will be overridden.

The main purpose of extending the DateTime class is to avoid the need to remember formatting characters, but there's no point replacing obscure characters with method names that are equally difficult to remember. One solution is to borrow function names from another Web technology; JavaScript springs to mind as a good candidate. Where a JavaScript equivalent doesn't exist, the names will be as descriptive as possible. Using descriptive names is one of the guidelines in the Zend Framework PHP Coding Standard; and if your IDE generates code hints from custom classes, there's no extra typing involved once the class has been defined.

It would also be useful to be able to perform calculations with dates, such as working out the number of days between two dates, and adding or subtracting a number of days or weeks from a specific point in time. In the original DateTime class, adding or subtracting a specific period is done with modify(); but I have decided to block its use in the extended class, so alternative methods are required.

So, in summary, the tasks required to extend the DateTime class are as follows:

  • Methods to be overridden:

    • __construct()

    • modify()

    • setTime()

    • setDate()

  • New methods for setting dates:

    • setMDY(): Accept a date in MM/DD/YYYY format

    • setDMY(): Accept a date in DD/MM/YYYY format

    • setFromMySQL(): Accept a date in MySQL format (YYYY-MM-DD)

  • New methods for displaying dates:

    • getMDY(): Display a date in MM/DD/YYYY format

    • getDMY(): Display a date in DD/MM/YYYY format

    • getMySQLFormat(): Format a date as YYYY-MM-DD ready for insertion into MySQL

  • New methods for displaying date parts:

    • Separate methods for displaying year, month, and date as numbers, words, and abbreviations

  • New methods for doing date calculations:

    • Separate methods for adding and subtracting days, weeks, months, and years

    • A static method for calculating the number of days between two dates

Note

Choosing names for methods is often subjective. I have used "MySQL" in the names of the methods that handle dates in the ISO format (YYYY-MM-DD) because MySQL is used so widely in conjunction with PHP. However, if you don't use MySQL, it might be more appropriate to change the names to setFromISO() and getISOFormat().

That's quite a lot of coding ahead. If you don't relish the prospect of typing it all out yourself, you can find the finished class in the download files. Even if you take the lazy route, do read through the explanations in the following pages, as they will help you understand the process of extending an existing class. The finished class in the download files is also fully commented.

Building the class

At long last, it's time to roll your sleeves up and create your first custom class. Let's start with the basic shell and the constructor.

Creating the class file and constructor

Following the naming convention I explained in Chapters 1 and Chapter 2, all custom classes in this book are prefixed with Pos_, and the name maps to the location of the class file. I'm going to call this class Pos_Date, so you need to create an empty PHP file called Date.php in the Pos folder.

Note

If you just want to review the final code along with the explanations in the text, copy Date.php from the finished_classes folder in the download files to the Pos folder.

The Pos_Date class extends the DateTime class. Since DateTime is part of the PHP core, it's automatically available to any script, so there's no need to include it before the class definition (in fact, you can't; there's nowhere to include it from).

  1. Declare the class name and use the extends keyword so that it inherits the existing methods of the DateTime class like this:

    class Pos_Date extends DateTime
    {
     // all code goes here
    }
  2. The purpose of a DateTime object is to store the date and time. It does so internally by storing a Unix timestamp, which represents the number of seconds elapsed since January 1, 1970. However, storing year, month, and date as properties within the class makes life easier. You don't want any of these properties to be changed arbitrarily, so they should be declared as protected. Add the properties between the curly braces of the class definition like this:

    protected $_year;
    protected $_month;
    protected $_day;

    Because they're protected properties, I have started each name with an underscore. Later, you'll set the values of these properties with arguments that use the same names without an underscore. This naming convention makes it easier to distinguish between values passed in from outside as arguments (no underscore) and values stored internally as properties (leading underscore).

  3. The next step is to create the constructor method. To start off with, let's create an object that's identical to one created by the DateTime class and assign values to some of the properties declared in the previous step. Add the following code beneath the properties:

    public function __construct($dateString, $timezone)
    {
      // call the parent constructor
      parent::__construct($dateString, $timezone);
    
      // assign the values to the class properties
      $this->_year = (int) $this->format('Y'),
      $this->_month = (int) $this->format('n'),
      $this->_day = (int) $this->format('j'),
    }

    This calls the parent constructor and passes the $dateString and $timezone arguments directly to it. As the code now stands, both arguments are required, so we'll need to fix this, but let's first have a look at the properties.

    Each property uses the format() method inherited from the DateTime class to extract the numeric value for the year, month, and day, using the following date() formatting characters:

    • Y: The year as a four-digit number

    • n: The number of the month with no leading zero

    • j: The day of the month as a number with no leading zero

    The format() method returns a string, so each value is cast to an integer, using the casting operator (int). Most of the time, PHP automatically converts numeric strings to integers, but I have taken this precaution to ensure that calculations are handled correctly with no unpleasant surprises.

    I have not assigned values to the time properties because I don't propose using them for any calculations. However, you might need to do so if you decide to add extra methods to the Pos_Date class later to handle time in greater detail.

Let's pause a moment to take a look at the arguments passed to the constructor. In the DateTime class, both are optional. However, the way DateTime handles invalid dates prompted me to allow this class to create an object only for the current date and time. So, instead of two arguments, I want only one—for the time zone—and it needs to be optional. There are two ways of handling optional arguments. One way is to assign a default value to the argument in the function definition like this:

public function __construct($timezone = null)

The other way is to leave out the arguments entirely, and to use func_get_args() and its related functions (see http://docs.php.net/manual/en/function.func-get-args.php) to find out how many arguments have been passed, and extract their values.

In this case, I'm going to take the first option, because leaving out the arguments prevents any automatic code hinting by an IDE. However, declaring $timezone as null also presents problems. Although a time zone is optional, if you choose to set it, the parent constructor expects it to be a DateTimeZone object. Anything else triggers an exception, so you need to check the value of $timezone before handing it off to the parent. With those points in mind, let's get back to the class definition.

  1. Amend the constructor so it looks like this:

    public function __construct($timezone = null)
    {
      // call the parent constructor
      if ($timezone) {
        parent::__construct('now', $timezone);
      } else {
        parent::__construct('now'),
      }
    
      // assign the values to the class properties
      $this->_year = (int) $this->format('Y'),
      $this->_month = (int) $this->format('n'),
      $this->_day = (int) $this->format('j'),
    }

    If $timezone has a value other than its default (null) or anything that PHP treats as false (such as 0 or an empty string), the conditional statement equates to true and passes two arguments to the parent constructor. Even though both arguments are optional in the DateTime class, you can't call the parent constructor with only the second argument. So, the value now is hardcoded for the date and time, and $timezone sets the time zone value passed as the sole argument to the Pos_Date constructor.

    If no argument is passed to the Pos_Date constructor, $timezone is null, so the conditional statement fails, and runs the else clause instead. This passes the hardcoded value now to set the date and time. Since this is the default value used by DateTime, you could leave this out. However, you must call the parent constructor—even if you pass no arguments to it—because the extended class has defined a constructor of its own, overriding the parent. Without an explicit call to the parent constructor, an object to hold the date and time won't be created, defeating the purpose of extending the DateTime class.

    Rather than omit the argument in the else block, I have chosen to hardcode the value now as a reminder of how I want the extended class to work. Explicit instructions remove any doubt.

    You might be wondering why I don't check whether $timezone is a DateTimeZone object. The answer is simple: if anything other than a DateTimeZone object is passed to the parent constructor, the DateTime class throws an exception. In other words, the parent class performs the check on your behalf.

  2. If you create an instance of the Pos_Date class now, it works in exactly the same way as a DateTime object, as it inherits all the public methods (apart from the constructor) listed in Table 3-1. Test it with the following code (in Pos_Date_test_01.php), which should produce output similar to Figure 3-9:

    require_once '../Pos/Date.php';
    try {
      // create a Pos_Date object for the default time zone
      $local = new Pos_Date();
      // use the inherited format() method to display the date and time
      echo '<p>Local time: ' . $local->format('F jS, Y h:i A') . '</p>';
    
      // create a DateTimeZone object
      $tz = new DateTimeZone('Asia/Tokyo'),
      // create a new Pos_Date object and pass the time zone as an argument
      $Tokyo = new Pos_Date($tz);
      echo '<p>Tokyo time: ' . $Tokyo->format('F jS, Y h:i A') . '</p>';
    } catch (Exception $e) {
      echo $e;
    }
    Confirmation that the Pos_Date class has inherited its parent's methods

    Figure 3.9. Confirmation that the Pos_Date class has inherited its parent's methods

    Notice that I have wrapped the script in a try . . . catch block. You should get into the habit of doing this when using code that might trigger an exception (see "Handling errors with exceptions" in Chapter 2).

The only difference between the two classes so far is that the only way to create an object for a different time and date is to use the inherited setTime() and setDate() methods. But these methods accept out-of-range values, so let's improve the way they work.

Resetting the time and date

Apart from the way they handle out-of-range values, there's nothing wrong with the setTime() and setDate() methods. So all that's needed to override them in the Pos_Date class is to check the validity of the arguments, and then pass them directly to the parent method.

  1. Let's start with setting the time. If you're typing out the code yourself, each method goes after the preceding one inside the curly braces of the class definition in Date.php. The code is very straightforward, so here is the complete listing for setTime():

    public function setTime($hours, $minutes, $seconds = 0)
    {
      if (!is_numeric($hours) || !is_numeric($minutes) || 
    Resetting the time and date
    !is_numeric($seconds)) { throw new Exception('setTime() expects two or three numbers
    Resetting the time and date
    separated by commas in the order: hours, minutes, seconds'), } $outOfRange = false; if ($hours < 0 || $hours > 23) { $outOfRange = true; } if ($minutes < 0 || $minutes > 59) { $outOfRange = true; } if ($seconds < 0 || $seconds > 59) { $outOfRange = true; } if ($outOfRange) { throw new Exception('Invalid time.'), } parent::setTime($hours, $minutes, $seconds); }

    In the parent class, the third argument is optional. If omitted, it sets seconds to zero, so when overriding setTime(), it's necessary to set a default value for $seconds in the method definition.

    The first conditional statement checks that all the arguments are numeric. If not, it throws an exception with a message informing users of the correct type and sequence of arguments. Strictly speaking, DateTime::setTime() expects each argument to be an integer. However, in practice, it's much more lenient. As long as the input is numeric, it accepts strings and floating point numbers and converts them to the equivalent integers. So there's no need to perform a stricter check than is_numeric(). This is extremely helpful, because all input is transmitted through the $_POST and $_GET arrays as strings. If the parent class didn't take this approach, you would need to convert all input to integers before passing it to the parent method.

    Before the next series of conditional statements, a variable called $outOfRange is set to false. Each conditional statement checks whether $hours, $minutes, and $seconds is in a valid range, and if not, $outOfRange is set to true. Since only one condition needs to fail, I could have put all six in a single statement. I have used three statements purely for readability. It involves a little extra typing, but I find the logic of the code is easier to follow when laid out like this.

    If $outOfRange is reset to true, an exception is thrown. Since PHP jumps straight to a catch block when an exception is thrown, there's no need to wrap the call to the parent method in an else clause. If no exception has been thrown, the arguments are passed to the parent method, and the time is reset.

  2. The code for setDate() is very similar, so here's the listing in full:

    public function setDate($year, $month, $day)
    {
      if (!is_numeric($year) || !is_numeric($month) || 
    Resetting the time and date
    !is_numeric($day)) { throw new Exception('setDate() expects three numbers separated
    Resetting the time and date
    by commas in the order: year, month, day.'), } if (!checkdate($month, $day, $year)) { throw new Exception('Non-existent date.'), } parent::setDate($year, $month, $day); $this->_year = (int) $year; $this->_month = (int) $month; $this->_day = (int) $day; }

    All three arguments are required for setDate(), so there's no need to set any default values. The first conditional statement again checks whether the submitted values are numeric. Although the parent class performs a similar check, you need to establish that you're working with numbers before checking they're in the correct range.

    This time, you want to make sure the values submitted constitute a valid date, so you pass them to the PHP checkdate() function, which is smart enough to know there are only 30 days in September and when it's a leap year. Notice that the order of arguments expected by checkdate() follows the American convention of month, day, year, unlike DateTime, which follows the ISO standard of largest unit to smallest: year, month, day.

    If no exception is thrown, the arguments are passed to the parent method. Finally, you need to reset the internal properties $_year, $_month, and $_day to their new values, casting them to integers in the process.

  3. Test the overridden setTime() and setDate() methods with the following code (it's in Pos_Date_test_02.php):

    require_once '../Pos/Date.php';
    try {
      // create a Pos_Date object for the default time zone
      $local = new Pos_Date();
      // use the inherited format() method to display the date and time
      echo '<p>Time now: ' . $local->format('F jS, Y h:i A') . '</p>';
      $local->setTime(12, 30);
      $local->setDate(2008, 8, 8);
      echo '<p>Date and time reset: ' . $local->format('F jS, Y h:i A') . '</p>';
    } catch (Exception $e) {
      echo $e;
    }

    This code should display the current date and time, and show the reset date and time as August 8th, 2008 12:30 PM.

  4. Change the date or time to an out-of-range value, and run the code again (there's an example in Pos_Date_test_03.php). It should catch the exception, and display details of the problem as shown in Figure 3-10.

    The overridden setTime() and setDate() methods reject out-of-range values.

    Figure 3.10. The overridden setTime() and setDate() methods reject out-of-range values.

    As you can see from Figure 3-10, displaying the exception with echo provides a lot of information about the problem: the custom message (Non-existent date), the file and the line that caused the problem (Pos_Date_test_03.php(9)), and the arguments passed to the method that threw the exception (setDate(2008, 9, 31)).

  5. The final step in closing the out-of-range loopholes in the parent class involves throwing an exception if anyone tries to use the modify() method inherited from DateTime. Add the following to the Pos_Date class definition:

    public function modify()
    {
      throw new Exception('modify() has been disabled.'),
    }

These simple changes make the Pos_Date class much more robust than the parent class. That completes overriding the inherited methods. All subsequent changes add new methods to enhance the class's functionality. You can either go ahead and implement all the new methods or just choose those that suit your needs. Let's start by adding some new ways to set the date.

Accepting dates in common formats

When handling dates in user input, my instinct tells me not to trust anyone to use the right format, so I create separate fields for year, month, and date. That way, I'm sure of getting the elements in the right order. However, there are circumstances when using a commonly accepted format can be useful, such as an intranet or when you know the date is coming from a reliable source like a database. I'm going to create methods to handle the three most common formats: MM/DD/YYYY (American style), DD/MM/YYYY (European style), and YYYY-MM-DD (the ISO standard, which is common in East Asia, as well as being the only date format used by MySQL). These methods have been added purely as a convenience. When both the month and date elements are between 1 and 12, there is no way of telling whether they have been inputted in the correct order. The MM/DD/YYYY format interprets 04/01/2008 as April 1, 2008, while the DD/MM/YYYY format treats it as January 4, 2008.

They all follow the same steps:

  1. Use the forward slash or other separator to split the input into an array.

  2. Check that the array contains three elements.

  3. Pass the elements (date parts) in the correct order to Pos_Date::setDate().

I pass the elements to the overridden setDate() method, rather than to the parent method, because the overridden method continues the process like this:

  1. It checks that the date parts are numeric.

  2. It checks the validity of the date.

  3. If everything is OK, it passes the date parts to the parent setDate() method and resets the object's $_year, $_month, and $_day properties.

This illustrates an important aspect of developing classes. When I originally designed these methods, all six steps were performed in each function. This involved not only a lot of typing; with four methods all doing essentially the same thing (the fourth is Pos_Date::setDate()), the likelihood of mistakes was quadrupled. So, even inside a class, it's important to identify duplicated effort and eliminate it by passing subordinate tasks to specialized methods. In this case, setDate() is a public method, but as you'll see later, it's common to create protected methods to handle repeated tasks that are internal to the class.

Accepting a date in MM/DD/YYYY format

This is the full listing for setMDY(), which accepts dates in the standard American format MM/DD/YYYY.

public function setMDY($USDate)
{
  $dateParts = preg_split('{[-/ :.]}', $USDate);
  if (!is_array($dateParts) || count($dateParts) != 3) {
    throw new Exception('setMDY() expects a date as "MM/DD/YYYY".'),
  }
  $this->setDate($dateParts[2], $dateParts[0], $dateParts[1]);
}

The first line inside the setMDY() method uses preg_split() to break the input into an array. I have used preg_split() instead of explode(), because it accepts a regular expression as the first argument. The regular expression {[-/ :.]} splits the input on any of the following characters: dash, forward slash, single space, colon, or period. This permits not only MM/DD/YYYY, but variations, such as MM-DD-YYYY or MM:DD:YYYY.

Note

Although Perl-compatible regular expressions are normally enclosed in forward slashes, I have used a pair of curly braces. This is because the regex contains a forward slash. Using braces avoids the need to escape the forward slash in the middle of the regex with a backslash. Regular expressions are hard enough to read without adding in the complication of escaping forward slashes.

As long as $dateParts is an array with three elements, the date parts are passed internally to the overridden setDate() method for the rest of the process. If there's anything wrong with the data, it's the responsibility of setDate() to throw an exception.

Accepting a date in DD/MM/YYYY format

The setDMY() method is identical to setMDY(), except that it passes the elements of the $dateParts array to setDate() in a different order to take account of the DD/MM/YYYY format commonly used in Europe and many other parts of the world. The full listing looks like this:

public function setDMY($EuroDate)
{
  $dateParts = preg_split('{[-/ :.]}', $EuroDate);
  if (!is_array($dateParts) || count($dateParts) != 3) {
    throw new Exception('setDMY() expects a date as "DD/MM/YYYY".'),
  }
  $this->setDate($dateParts[2], $dateParts[1], $dateParts[0]);
}

Accepting a date in MySQL format

This works exactly the same as the previous two methods. Although MySQL uses only a dash as the separator between date parts, I have left the regular expression unchanged, so that the setFromMySQL() method can be used with dates from other sources that follow the same ISO format as MySQL. The full listing follows:

public function setFromMySQL($MySQLDate)
{
  $dateParts = preg_split('{[-/ :.]}', $MySQLDate);
  if (!is_array($dateParts) || count($dateParts) != 3) {
    throw new Exception('setFromMySQL() expects a date as "YYYY-MM-DD".'),
  }
    $this->setDate($dateParts[0], $dateParts[1], $dateParts[2]);
}

Now let's turn to formatting dates, starting with the most commonly used formats.

Outputting dates in common formats

Outputting a date is simply a question of wrapping—or encapsulating, to use the OOP terminology—the format() method and its cryptic formatting characters in a more user-friendly name. But what should you do about leading zeros? Rather than create separate methods for MM/DD/YYYY and DD/MM/YYYY formats with and without leading zeros, the simple approach is to pass an argument to the method. The code is so simple; the full listing for getMDY(), getDMY(), and getMySQLFormat() follows:

public function getMDY($leadingZeros = false)
{
  if ($leadingZeros) {
    return $this->format('m/d/Y'),
  } else {
    return $this->format('n/j/Y'),
  }
}

public function getDMY($leadingZeros = false)
{
  if ($leadingZeros) {
    return $this->format('d/m/Y'),
  } else {
    return $this->format('j/n/Y'),
  }
}

public function getMySQLFormat()
{
  return $this->format('Y-m-d'),
}

I have assumed that most people will want to omit leading zeros, so I have given the $leadingZeros argument a default value of false. Inside each method, different formatting characters are passed to the format() method depending on the value of $leadingZeros. The ISO format used by MySQL normally uses leading zeros, so I have not provided an option to omit them in getMySQLFormat().

Because $leadingZeros has a default value, there's no need to pass an argument to getMDY() or getDMY() if you don't want leading zeros. If you do, anything that PHP treats as true, such as 1, will suffice. The following code (in Pos_Date_test_04.php) produces the output shown in Figure 3-11:

require_once '../Pos/Date.php';
try {
  // create a Pos_Date object
  $date = new Pos_Date();
  // set the date to July 4, 2008
  $date->setDate(2008, 7, 4);
  // use different methods to display the date
  echo '<p>getMDY(): ' . $date->getMDY() . '</p>';
  echo '<p>getMDY(1): ' . $date->getMDY(1) . '</p>';
  echo '<p>getDMY(): ' . $date->getDMY() . '</p>';
  echo '<p>getDMY(1): ' . $date->getDMY(1) . '</p>';
  echo '<p>getMySQLFormat(): ' . $date->getMySQLFormat() . '</p>';
} catch (Exception $e) {
  echo $e;
}
The output methods make it easy to format a date in a variety of ways.

Figure 3.11. The output methods make it easy to format a date in a variety of ways.

Outputting date parts

As well as full dates, it's useful to be able to extract individual date parts. Wherever possible, I have chosen method names from JavaScript to make them easy to remember. The following code needs no explanation, as it uses either the format() method with the appropriate formatting character or one of the class's properties:

public function getFullYear()
{
  return $this->_year;
}

public function getYear()
{
  return $this->format('y'),
}

public function getMonth($leadingZero = false)
{
  return $leadingZero ? $this->format('m') : $this->_month;
}

public function getMonthName()
{
  return $this->format('F'),
}

public function getMonthAbbr()
{
  return $this->format('M'),
}

public function getDay($leadingZero = false)
{
  return $leadingZero ? $this->format('d') : $this->_day;
}

public function getDayOrdinal()
{
  return $this->format('jS'),
}

public function getDayName()
{
  return $this->format('l'),
}

public function getDayAbbr()
{
  return $this->format('D'),
}

You can check the output of these methods with Pos_Date_test_05.php in the download files. It displays results similar to Figure 3-12 for the current date.

The method names give intuitive access to date parts.

Figure 3.12. The method names give intuitive access to date parts.

Performing date-related calculations

The traditional way of performing date calculations, such as adding or subtracting a number of weeks or days, involves tedious conversion to a Unix timestamp, performing the calculation using seconds, and then converting back to a date. That's why strtotime() is so useful. Although it works with Unix timestamps, it accepts date-related calculations in human language, for example, +2 weeks, −1 month, and so on. That's probably why the developer of the DateTime class decided to model the constructor and modify() methods on strtotime().

As I have already mentioned, strtotime() converts out-of-range dates to what PHP thinks you meant. The same problem arises when you use DateTime::modify() to perform some date-related calculations. The following code (in date_test_10.php) uses modify() to add one month to a date, and then subtract it:

// create a DateTime object
$date = new DateTime('Aug 31, 2008'),
echo '<p>Initial date is ' . $date->format('F j, Y') . '</p>';
// add one month
$date->modify('+1 month'),
echo '<p>Add one month: ' . $date->format('F j, Y') . '</p>';
// subtract one month
$date->modify('-1 month'),
echo '<p>Subtract one month: ' . $date->format('F j, Y') . '</p>';

Figure 3-13 shows the output of this calculation. It's almost certainly not what you want. Because September doesn't have 31 days, DateTime::modify() converts the result to October 1 when you add one month to August 31. However, subtracting one month from the result doesn't bring you back to the original date. The second calculation is correct, because October 1 minus one month is September 1. It's the first calculation that's wrong. Most people would expect that adding one month to the last day of August would produce the last day of the next month, in other words September 30. The same problem happens when you add 1, 2, or 3 years to the last day of February in a leap year. February 29 is converted to March 1.

DateTime::modify() produces unexpected results when performing some date calculations.

Figure 3.13. DateTime::modify() produces unexpected results when performing some date calculations.

In spite of these problems, DateTime::modify() is ideal for adding and subtracting days or weeks. So, that's what I'll use for those calculations, but for working with months and years, different solutions will need to be found.

Note

What about subtracting one month from September 30? Should the result be August 30 or 31? I have decided that it should be August 30 for the simple reason that August 30 is exactly one month before September 30. The problem with this decision is that you won't necessarily arrive back at the same starting date in a series of calculations that add and subtract months. This type of design decision is something you will encounter frequently. The important thing is that the class or method produces consistent results in accordance with the rules you have established. If arriving back at the same starting date is vital to the integrity of your application, you would need to design the methods differently.

If you think I shot myself in the foot earlier on by overriding modify() to make it throw an exception, think again. Overriding modify() prevents anyone from using it with a Pos_Date object, but that doesn't prevent you from using the parent method inside the class definition. You can still access it internally with the parent keyword. This is what encapsulation is all about. The end user has no need to know about the internal workings of a method; all that matters to the user is that it works.

Adding and subtracting days or weeks

Another problem with DateTime::modify() and strtotime() is that they accept any string input. If the string can be parsed into a date expression, everything works fine; but if PHP can't make sense of the string, it generates an error. So, it's a good idea to cut down the margin for error as much as possible. The four new methods, addDays(), subDays(), addWeeks(), and subWeeks() accept only a number; creation of the string to be passed to the parent modify() method is handled internally. The following listing shows all four methods:

public function addDays($numDays)
{
  if (!is_numeric($numDays) || $numDays < 1) {
    throw new Exception('addDays() expects a positive integer.'),
  }
  parent::modify('+' . intval($numDays) . ' days'),
}

public function subDays($numDays)
{
  if (!is_numeric($numDays)) {
    throw new Exception('subDays() expects an integer.'),
  }
  parent::modify('-' . abs(intval($numDays)) . ' days'),
}

public function addWeeks($numWeeks)
{
  if (!is_numeric($numWeeks) || $numWeeks < 1) {
    throw new Exception('addWeeks() expects a positive integer.'),
  }
  parent::modify('+' . intval($numWeeks) . ' weeks'),
}

public function subWeeks($numWeeks)
{
  if (!is_numeric($numWeeks)) {
    throw new Exception('subWeeks() expects an integer.'),
  }
  parent::modify('-' . abs(intval($numWeeks)) . ' weeks'),
}

Each method follows the same pattern. It begins by checking whether the argument passed to it is numeric. In the case of addDays() and addWeeks(), I have also checked whether the number is less than 1. I did this because it seems to make little sense to accept a negative number in a method designed to add days or weeks. For some time, I toyed with the idea of creating just two methods (one each for days and weeks) and getting the same method to add or subtract depending on whether the number is positive or negative. In the end, I decided that an explicit approach was cleaner.

Since is_numeric() accepts floating point numbers, I pass the number to intval() to make sure that only an integer is incorporated into the string passed to parent::modify().

Another design problem that I wrestled with for some time was whether to accept negative numbers as arguments to subDays() and subWeeks(). The names of the methods indicate that they subtract a specified number of days or weeks, so most people are likely to insert a positive number. However, you could argue that a negative number is just as logical. One solution is to throw an exception if a negative number is submitted. In the end, I opted to accept either negative or positive numbers, but to treat them as if they mean the same thing. In other words, to subtract 3 days from the date regardless of whether 3 or −3 is passed to subDays(). So, the string built inside subDays() and subWeeks() uses abs() and intval() as nested functions like this:

'-' . abs(intval($numWeeks)) . ' weeks'

If you're not familiar with nesting functions like this, what happens is that the innermost function is processed first, and the result is then processed by the outer function. So, intval($numWeeks) converts $numWeeks to an integer, and abs() converts the result to its absolute value (in other words, it turns a negative number into a positive one). So, if the value passed to subWeeks() is −3.25, intval() converts $numWeeks to −3, and abs() subsequently converts it to 3. The resulting string passed to parent::modify() is '-3 weeks'.

You can test these methods in Pos_Date_test_06.php through Pos_Date_test_09.php in the download files.

Adding months

A month can be anything from 28 to 31 days in length, so it's impossible to calculate the number of seconds you need to add to a Unix timestamp—at least, not without some fiendishly complicated formula. The solution I have come up with is to add the number of months to the current month. If it comes to 12 or less, you have the new month number. If it's more than 12, you need to calculate the new month and year. Finally, you need to work out if the resulting date is valid. If it isn't, it means you have ended up with a date like February 30, so you need to find the last day of the new month, taking into account the vagaries of leap years.

It's not as complicated as it sounds, but before diving into the PHP code, it's easier to understand the process with some real figures.

Let's take February 29, 2008, as the starting date. In terms of the Pos_Date properties, that looks like this:

$_year = 2008;
$_month = 2;
$_day = 29;

Add 9 months:

$_month += 9;  // result is 11

The result is 11 (November). This is less than 12, so the year remains the same. November 29 is a valid date, so the calculation is simple.

Instead of 9 months, add 12:

$_month += 12;  // result is 14

There isn't a fourteenth month in a year, so you need to calculate both the month and the year. To get the new month, use modulo division by 12 like this:

14 % 12;  // result is 2

Modulo returns the remainder of a division, so the new month is 2 (February). To calculate how many years to add, divide 14 by 12 and round down to the nearest whole number using floor() like this:

$_year += floor(14 / 12);  // adds 1 to the current year

This works fine for every month except December. To understand why, add 22 months to the starting date:

$_month += 22;  // result is 24
$remainder = 24 % 12;  // result is 0

Dividing 24 by 12 produces a remainder of 0. Not only is there no month 0, division by 12 produces the wrong year as this calculation shows:

$_year += floor(24 / 12);  // adds 2 to the current year

Any multiple of 12 produces a whole number, and since there's nothing to round down, the result is always 1 greater than you want. Adding 2 to the year produces 2010; but 22 months from the starting date should be December 29, 2009. So, you need to subtract 1 from the year whenever the calculation produces a date in December.

Finally, you need to check that the resulting date is valid. Adding 12 months to the starting date produces February 29, 2009, but since 2009 isn't a leap year, you need to adjust the result to the last day of the month.

With all that in mind, you can map out the internal logic for addMonths() like this:

  1. Add months to the existing month number ($_month), and call it $newValue.

  2. If $newValue is less than or equal to 12, use it as the new month number, and skip to step 6.

  3. If $newValue is greater than 12, do modulo division by 12 on $newValue. If this produces a remainder, use the remainder as the new month number, and proceed to step 4. If there is no remainder, you know the month must be December, so go straight to step 5.

  4. Divide $newValue by 12, round down the result to the next whole number, and add it to the year. Jump to step 6.

  5. Set the month number to 12, and divide $newValue by 12. Add the result of the division to the year, and subtract one.

  6. Check that the resulting date is valid. If it isn't, reset the day to the last day of the month, taking into account leap year.

  7. Pass the amended $_year, $_month, and $_day properties to setDate().

The full listing for addMonths(), together with inline comments, looks like this:

public function addMonths($numMonths)
{
  if (!is_numeric($numMonths) || $numMonths < 1) {
    throw new Exception('addMonths() expects a positive integer.'),
  }
  $numMonths = (int) $numMonths;
  // Add the months to the current month number.
  $newValue = $this->_month + $numMonths;
  // If the new value is less than or equal to 12, the year
  // doesn't change, so just assign the new value to the month.
  if ($newValue <= 12) {
    $this->_month = $newValue;
  } else {
    // A new value greater than 12 means calculating both
    // the month and the year. Calculating the year is
    // different for December, so do modulo division
    // by 12 on the new value. If the remainder is not 0,
    // the new month is not December.
    $notDecember = $newValue % 12;
    if ($notDecember) {
      // The remainder of the modulo division is the new month.
      $this->_month = $notDecember;
      // Divide the new value by 12 and round down to get the
      // number of years to add.
      $this->_year += floor($newValue / 12);
    } else {
      // The new month must be December
      $this->_month = 12;
      $this->_year += ($newValue / 12) - 1;
    }
  }
  $this->checkLastDayOfMonth();
  parent::setDate($this->_year, $this->_month, $this->_day);
}

The preceding explanation and the inline comments explain most of what's going on here. The result of the modulo division is saved as $notDecember and then used to control a conditional statement. Dividing $newValue by 12 produces a remainder for any month except December. Since PHP treats any number other than 0 as true, the first half of the conditional statement will be executed. If there's no remainder, $notDecember is 0, which PHP treats as false, so the else clause is executed instead.

All that remains to explain are the last two lines, the first of which calls an internal method called checkLastDayOfMonth(). This now needs to be defined.

Adjusting the last day of the month

The reason for not using DateTime::modify() is to prevent the date from being shifted to the first of the following month, so you need to check the validity of the resulting date before passing it to setDate() and reset the value of $_day to the last day of the month, if necessary. That operation is performed by the checkLastDayofMonth() method, which looks like this:

final protected function checkLastDayOfMonth()
{
  if (!checkdate($this->_month, $this->_day, $this->_year)) {
    $use30 = array(4 , 6 , 9 , 11);
    if (in_array($this->_month, $use30)) {
      $this->_day = 30;
    } else {
      $this->_day = $this->isLeap() ? 29 : 28;
    }
  }
}

The first thing to note about this method is that, unlike all other methods defined so far, this is both final and protected. This prevents anyone from either calling it directly or overriding it in a subclass. The reason I don't want it to be called directly is because it changes the value of the $_day property, so could result in arbitrary changes to your code. A protected method can be called only inside the class itself or a subclass. If it results in arbitrary changes to the date being stored, the only person to blame is yourself for making a mistake in the code.

The method passes the values of $_month, $_day, and $_year to checkdate(), which as you have seen before, returns false if the date is nonexistent. If the date is OK, the method does nothing. However, if checkdate() returns false, the method examines the value of $_month by comparing it with the $use30 array. April (4), June (6), September (9), and November (11) all have 30 days. If the month is one of these, $_day is set to 30.

If the month isn't in the $use30 array, it can only be February. In most years, the last day of the month will be 28, but in leap year, it's 29. The task of checking whether it's a leap year is handed off to another method called isLeap(), which returns true or false. If isLeap() returns true, the conditional operator sets $_day to 29. Otherwise, it's set to 28.

Checking for leap year

Leap years occur every four years on years that are wholly divisible by 4. The exception is that years divisible by 100 are not leap years unless they are also divisible by 400. Translating that formula into a conditional statement that returns true or false produces the following method:

public function isLeap()
{
  if ($this->_year % 400 == 0 || ($this->_year % 4 == 0 && 
Adding months
$this->_year % 100 != 0)) { return true; } else { return false; } }

I've made this method public because it doesn't affect any internal properties and could be quite useful when working with Pos_Date objects. It bases its calculation on the value of $_year, so doesn't rely on the date being set to February.

Because checkLastDayOfMonth() has already checked the validity of the date, and the values of $_year, $_month, and $_day have already been adjusted, they can be passed directly to the parent setDate() method to save a little processing time.

Figure 3-14 shows the different results produced by Pos_Date::addMonths() and DateTime::modify() when simply changing the month number would produce an invalid date (the code is in Pos_Date_test_10.php).

The addMonths() method adjusts the date automatically to the last day of the month if necessary.

Figure 3.14. The addMonths() method adjusts the date automatically to the last day of the month if necessary.

Subtracting months

Subtracting months requires similar logic. You start by subtracting the number of months from the current month number. If the result is greater than zero, the calculation is easy: you're still in the same year, and the result is the new month number. However, if you end up with 0 or a negative number, you've gone back to a previous year.

Let's take the same starting date as before, February 29, 2008. Subtract two months:

$_month -= 2;  // result is 0

It's easy to work out in your head that subtracting two months from February 29, 2008, results in December 29, 2007. What about subtracting three months? That brings you to November, and a month number of −1. Deduct four months, and you get October and a month number of −2. Hmm, a pattern is forming here that should be familiar to anyone who has worked with PHP for some time. If you remove the minus signs, you get a zero-based array. So, that's the solution: create an array of month numbers in reverse, and use the absolute value of deducting the number of months from the original month number as the array key. You could type out all the numbers like this:

$months = array(12 , 11 , 10 , 9 , 8 , 7 , 6 , 5 , 4 , 3 , 2 , 1);

However, it's much simpler to use the range() function, which takes two arguments and creates an array of integers or characters from the first argument to the second, inclusive. If the first argument is greater than the second one, the values in the array are in descending order. The following line of code produces the same result as the previous one:

$months = range(12, 1);

PHP arrays begin from 0, so $months[0] is 12, $months[1] is 11, and so on.

Also, because you're working in reverse from addMonths(), the year calculations round up, rather than down.

The full listing follows:

public function subMonths($numMonths)
{
  if (!is_numeric($numMonths)) {
    throw new Exception('addMonths() expects an integer.'),
  }
  $numMonths = abs(intval($numMonths));
  // Subtract the months from the current month number.
  $newValue = $this->_month - $numMonths;
  // If the result is greater than 0, it's still the same year,
  // and you can assign the new value to the month.
  if ($newValue > 0) {
    $this->_month = $newValue;
  } else {
    // Create an array of the months in reverse.
    $months = range(12 , 1);
    // Get the absolute value of $newValue.
    $newValue = abs($newValue);
    // Get the array position of the resulting month.
    $monthPosition = $newValue % 12;
    $this->_month = $months[$monthPosition];
    // Arrays begin at 0, so if $monthPosition is 0,
    // it must be December.
    if ($monthPosition) {
      $this->_year -= ceil($newValue / 12);
    } else {
$this->_year -= ceil($newValue / 12) + 1;
    }
  }
  $this->checkLastDayOfMonth();
  parent::setDate($this->_year, $this->_month, $this->_day);
}

As Figure 3-15 shows, using DateTime::modify() can produce radically unexpected results with some date calculations (the code is in Pos_Date_test_11.php). Pos_Date::subMonths() gives the right answer.

The subMonths() method correctly identifies the last day of the month when appropriate.

Figure 3.15. The subMonths() method correctly identifies the last day of the month when appropriate.

Adding and subtracting years

The code for addYears() and subYears() needs very little explanation. Changing the year simply involves adding or subtracting a number from $_year. The only date that causes a problem is February 29, but that's easily dealt with by calling checkLastDayOfMonth() before passing the $_year, $_month, and $_day properties to parent::setDate(). The full listing follows:

public function addYears($numYears)
{
  if (!is_numeric($numYears) || $numYears < 1) {
    throw new Exception('addYears() expects a positive integer.'),
  }
  $this->_year += (int) $numYears;
  $this->checkLastDayOfMonth();
  parent::setDate($this->_year, $this->_month, $this->_day);
}

public function subYears($numYears)
{
  if (!is_numeric($numYears)) {
    throw new Exception('subYears() expects an integer.'),
  }
$this->_year -= abs(intval($numYears));
  $this->checkLastDayOfMonth();
  parent::setDate($this->_year, $this->_month, $this->_day);
}

You can see examples of both methods in action in Pos_Date_test_12.php and Pos_Date_test_13.php.

Calculating the number of days between two dates

MySQL made calculating the number of days between two dates very easy with the introduction of the DATEDIFF() function in version 4.1.1. With PHP, though, you still need to do the calculation with Unix timestamps. The Pos_Date::dateDiff() method acts as a wrapper for this calculation. It takes two arguments, both Pos_Date objects representing the start date and the end date. Both dates are used to create Unix timestamps with gmmktime(). The difference in seconds is divided by 60 × 60 × 24 before being returned. The timestamps are set to midnight UTC to avoid problems with daylight saving time and make sure that deducting one from the other results in an exact number of days. If the date in the first argument is earlier than the second, a positive number is returned. If it's later, a negative number is returned. The code for the method looks like this:

static public function dateDiff(Pos_Date $startDate, Pos_Date $endDate)
{
  $start = gmmktime(0, 0, 0, $startDate->_month, $startDate->_day, 
Calculating the number of days between two dates
$startDate->_year); $end = gmmktime(0, 0, 0, $endDate->_month, $endDate->_day,
Calculating the number of days between two dates
$endDate->_year); return ($end - $start) / (60 * 60 * 24); }

An important thing to note about this method definition is that it's prefixed with the static keyword. This turns it into a static (or class) method. Instead of applying the method to a Pos_Date object, you call it directly using the class name and the scope resolution operator like this:

Pos_Date::dateDiff($argument1, $argument2);

The other thing to note is that the argument block uses type hinting (see Chapter 2 for details). Both arguments must be Pos_Date objects. If you try to pass anything else as an argument, the method throws an exception.

The following code (in Pos_Date_test_14.php) shows an example of how Pos_Date::dateDiff() might be used:

require_once '../Pos/Date.php';
try {
  // create two Pos_Date objects
  $now = new Pos_Date();
  $newYear = new Pos_Date();
  // set one of them to January 1, 2009
  $newYear->setDate(2009, 1, 1);
// calculate the number of days
  $diff = Pos_Date::dateDiff($now, $newYear);
  $unit = abs($diff) > 1 ? 'days' : 'day';
  if ($diff == 0) {
      echo 'Happy New Year!';
  } elseif ($diff > 0) {
      echo "$diff $unit left till 2009";
  } else {
      echo abs($diff) . " $unit since the beginning of 2009";
  }
} catch (Exception $e) {
  echo $e;
}

This method poses another design dilemma. I have made it static because it works on two different Pos_Date objects. It also gives me an opportunity to demonstrate a practical example of creating and using a static method. You could argue, however, that it would be much simpler to compare two dates by passing one as an argument to the other. Both approaches are perfectly valid.

As an exercise, you might like to try your hand at modifying dateDiff() so that it works as an ordinary public method. The alternative solution is defined as dateDiff2() in Date.php, and Pos_Date_test_15.php demonstrates how it's used.

Creating a default date format

Finally, let's use the magic __toString() method to create a default format for displaying dates. The code is very simple, and looks like this:

public function __toString()
{
  return $this->format('l, F jS, Y'),
}

Which format you use is up to you, but the one I have chosen displays a Pos_Date object as in Figure 3-16.

Defining the magic __toString() method makes it easy to display dates in a specific format.

Figure 3.16. Defining the magic __toString() method makes it easy to display dates in a specific format.

With this method defined, you can use echo, print, and other string functions to display a Pos_Date object in a predefined format. The code that produced the output in Figure 3-16 looks like this (it's in Pos_Date_test_16.php):

$now = new Pos_Date();
echo $now;

The important thing to remember when defining __toString() is that it must return a string. Don't use echo or print inside __toString().

Creating read-only properties

The class definition is now complete, but before ending the chapter, I'd like to make a brief detour to look at an alternative way of accessing predefined formats and date parts. Because the $_year, $_month, and $_day properties are protected, you can't access them outside the class definition. The only way to access them is to change them from protected to public, but once you do that, their values can be changed arbitrarily. That's clearly unacceptable, so the class has defined a series of getter methods that give public access to the values, but prevent anyone from changing them except through the setDate() and setTime() methods, or one of the date calculation methods.

Many developers find getter methods inconvenient and prefer to use properties. Using the magic __get() method (see Chapter 2), in combination with a switch statement, it's possible to create read-only properties. As I explained in the previous chapter, a common way to use __get() is to store the properties as elements of an associative array. However, you can also use a switch statement to determine the value to return when an undefined property is accessed in an outside script.

The following listing shows how I have defined the __get() magic method for the Pos_Date class:

public function __get($name)
{
  switch (strtolower($name)) {
    case 'mdy':
      return $this->format('n/j/Y'),
    case 'mdy0':
      return $this->format('m/d/Y'),
    case 'dmy':
      return $this->format('j/n/Y'),
    case 'dmy0':
      return $this->format('d/m/Y'),
    case 'mysql':
      return $this->format('Y-m-d'),
    case 'fullyear':
      return $this->_year;
case 'year':
      return $this->format('y'),
    case 'month':
      return $this->_month;
    case 'month0':
      return $this->format('m'),
    case 'monthname':
      return $this->format('F'),
    case 'monthabbr':
      return $this->format('M'),
    case 'day':
      return $this->_day;
    case 'day0':
      return $this->format('d'),
    case 'dayordinal':
      return $this->format('jS'),
    case 'dayname':
      return $this->format('l'),
    case 'dayabbr':
      return $this->format('D'),
    default:
      return 'Invalid property';
  }
}

You can probably recognize that this closely resembles the code in the getter methods in "Outputting date parts" earlier in the chapter. The switch statement compares $name to each case until it finds a match. I've passed $name to strtolower() to make the comparison case-insensitive. If it finds a match, it returns the value indicated. So, if it encounters MDY, it returns the date in MM/DD/YYYY format.

Note

You don't need break statements after each case because return automatically prevents the switch statement from going any further.

The value of creating this __get() method is that it simplifies code that uses the Pos_Date class. When used with a Pos_Date object called $date, the following two lines of code do exactly the same as each other:

echo $date->getMDY();
echo $date->MDY;

In most cases, I have used names based on the equivalent getter method. To indicate the use of leading zeros, I added 0 (zero) to the end of the name. So, DMY0 produces a date in DD/MM/YYYY format with leading zeros.

The disadvantages with using __get() like this are that the switch statement can easily become unwieldy, and it's impossible for an IDE to generate code hints for this sort of property.

You can see examples of these read-only properties in use in Pos_Date_test_17.php.

Organizing and commenting the class file

When developing a class, I normally create each new method at the bottom of the file, adding properties to the top of the file whenever necessary. Class files can become very long, so once I'm happy with the way the class works, I reorganize the code into logical groups in this order:

  1. Constants

  2. Properties

  3. Static methods

  4. Constructor

  5. Overridden methods

  6. New public methods grouped by functionality

  7. Protected and private methods

The next job is to add comments in PHPDoc format to describe briefly what each property and method is for. Writing comments is most developers' least favorite task, but the time spent doing it is usually repaid many times over when you come to review code several months or even years later. Write the comments while everything is still fresh in your mind.

Not only do the comments generate code hints in specialized IDEs, as described in the previous chapter, you can also use PHPDocumentor to generate detailed documentation in a variety of formats, including HTML and PDF. PHPDocumentor is built into specialized IDEs, such as Zend Studio for Eclipse and PhpED. Alternatively, you can download it free of charge from the PHPDocumentor web site at www.phpdoc.org.

Once you have finished commenting the class file, it takes PHPDocumentor literally seconds to generate detailed documentation. It creates hyperlinked lists of properties and methods organized in alphabetical order with line numbers indicating where to find the code in the class file. The PHPDoc comments aren't shown in the code listings in this book, but the download versions of the class files are fully commented. The download files also include the documentation generated by PHPDocumentor for all the classes featured in this book. Double-click index.html in the class_docs folder to launch them in a browser (see Figure 3-17).

PHPDocumentor makes it easy to build online documentation for your custom classes.

Figure 3.17. PHPDocumentor makes it easy to build online documentation for your custom classes.

Note

Standards warriors will probably be horrified that PHPDocumentor doesn't generate 100 percent valid XHTML. However, it's much faster than attempting to type out all the documentation manually, and the coding errors don't affect the display in current browsers.

Chapter review

If you have typed out all the code for the Pos_Date class, you might be thinking the chapter should be retitled "A Lot of Pain for Not Much in Return." Even without PHPDoc comments, the class definition file comes to more than 350 lines long. This is far from unusual in OOP. The idea is to create a library of tested and reusable code. When designing this class, I added new features only as I needed them. What you have seen here is the fruit of work over a much longer period of time than it has taken you to read this chapter.

I chose this class as the first practical exercise because, without getting too complex, it demonstrates the main features of OOP: inheritance, encapsulation, polymorphism, magic methods, and static methods. You might be surprised at how short the code is in many of the methods. That's not only because this class performs relatively simple tasks but also because a key concept in OOP is to break down code into discrete units: wherever possible, one task per method. If you attempt to do everything in a single method, your code becomes more rigid and more difficult to maintain.

One of the main purposes of this class is to avoid the need to use the unintuitive date formatting characters. The next chapter takes a similar approach: using encapsulation to hide the complexity of the PHP filter functions to create a class to validate and filter user input.

Note

As this book was about to go to press, details emerged of proposed changes to the DateTime class in PHP 5.3, including the addition of date_add(), date_sub(), and date_diff() methods. When used in PHP 5.3 and above, the Pos_Date class will inherit these new methods alongside its own date calculation methods.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.133.92.215